Source code for toupy.restoration.phase_retrieval

#!/usr/bin/env python
# -*- coding: utf-8 -*-

"""
Phase retrieval for propagation-based X-ray imaging.

Two methods are provided:

TIE-Hom (Paganin et al., 2002)
    Single-distance phase retrieval assuming a homogeneous object
    (constant delta/beta ratio).  Extremely robust and the de-facto
    standard for synchrotron nanotomography.

    Reference:
        Paganin, D., Mayo, S. C., Gureyev, T. E., Miller, P. R., &
        Wilkins, S. W. (2002). Simultaneous phase and amplitude
        extraction from a single defocused image of a homogeneous object.
        Journal of Microscopy, 206(1), 33–40.
        https://doi.org/10.1046/j.1365-2818.2002.01010.x

CTF (Contrast Transfer Function)
    Linear phase retrieval in the weak-phase / weak-absorption regime,
    valid for a single propagation distance.  Handles the zeros of the
    CTF via Tikhonov regularisation.

    Reference:
        Turner, L. D., Dhal, B. B., Hayes, J. P., Mancuso, A. P.,
        Nugent, K. A., Paterson, D., ... & Peele, A. G. (2004).
        X-ray phase imaging: Demonstration of extended conditions
        for homogeneous objects. Optics Express, 12(13), 2960–2965.
        https://doi.org/10.1364/OPEX.12.002960

GPU notes
---------
Both functions accept ``cuda=True`` to run entirely on a CuPy GPU array.
The input may be a CPU numpy array (it is uploaded automatically) and the
output is always returned as a CPU numpy array.  When a stack (3-D array)
is passed with ``cuda=True``, the full stack is batched on the GPU so that
only one upload/download round-trip occurs regardless of the number of
projections.
"""

import warnings
import numpy as np
from scipy.fft import fft2, ifft2, fftfreq

# ---------------------------------------------------------------------------
# Optional CuPy import — graceful degradation to CPU if unavailable
# ---------------------------------------------------------------------------
try:
    import cupy as cp
    CUDA_AVAILABLE = True
except ImportError:
    CUDA_AVAILABLE = False

__all__ = ["tie_hom", "ctf_retrieve", "iterative_phase_retrieval",
           "suggest_holo_distances"]


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------

def _get_xp(cuda):
    """Return cupy if cuda is requested and available, else numpy."""
    if cuda:
        if not CUDA_AVAILABLE:
            warnings.warn(
                "CuPy not available — falling back to CPU phase retrieval.",
                stacklevel=3,
            )
            return np
        return cp
    return np


def _freq_grid(ny, nx, pixel_size, xp):
    """Return (fy, fx) frequency grids in units of 1/pixel_size."""
    fy = xp.fft.fftfreq(ny, d=pixel_size)
    fx = xp.fft.fftfreq(nx, d=pixel_size)
    return xp.meshgrid(fy, fx, indexing="ij")


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

[docs] def tie_hom( intensity, distance, wavelength, pixel_size, delta_beta=1e3, regularisation=1e-3, cuda=False, ): """ Single-distance TIE-Hom (Paganin) phase retrieval. Recovers the projected thickness (or phase) from a single defocused intensity image assuming a homogeneous object with a fixed delta / beta ratio. The filter in frequency space is: .. math:: \\hat{T}(\\mathbf{u}) = \\frac{\\mathcal{F}\\{I / I_0\\}} {1 + \\pi\\,\\lambda\\,z\\,(\\delta/\\beta)\\,|\\mathbf{u}|^2} where the projected thickness is :math:`T = -1/(\\mu) \\ln \\hat{T}`, and the phase is :math:`\\phi = -T \\cdot (\\pi / (\\lambda z))`. Parameters ---------- intensity : ndarray, shape (M, N) or (K, M, N) Measured intensity image(s). A 3-D array is treated as a stack of projections processed as a single GPU batch when ``cuda=True``. distance : float Sample-to-detector propagation distance [m]. wavelength : float X-ray wavelength [m]. pixel_size : float Effective detector pixel size at the sample plane [m]. delta_beta : float, optional Ratio delta/beta of the complex refractive index decrement. Typical values: 1e2–1e4 for biological soft tissue; 1e1–1e2 for metals. Default 1e3. regularisation : float, optional Tikhonov regularisation parameter alpha. Prevents division by zero when the denominator approaches unity. Default 1e-3. cuda : bool, optional Run on GPU via CuPy. Falls back to CPU with a warning if CuPy is unavailable. Default False. Returns ------- phase : ndarray, same shape as *intensity* Retrieved phase image(s) [rad]. Always returned as a CPU array. Examples -------- >>> import numpy as np >>> from toupy.restoration import tie_hom >>> img = np.ones((256, 256)) >>> phase = tie_hom(img, distance=0.1, wavelength=1e-10, ... pixel_size=1e-7, delta_beta=500) """ xp = _get_xp(cuda) intensity = np.asarray(intensity, dtype=float) stack = intensity.ndim == 3 if not stack: intensity = intensity[np.newaxis] use_gpu = cuda and CUDA_AVAILABLE # Upload entire stack in one round-trip when on GPU arr = xp.asarray(intensity) if use_gpu else intensity ny, nx = arr.shape[-2:] fy, fx = _freq_grid(ny, nx, pixel_size, xp) freq2 = fy**2 + fx**2 denom = 1.0 + np.pi * wavelength * distance * delta_beta * freq2 denom = xp.maximum(denom, regularisation) # Batch FFT: fft2 on a 3-D array operates on the last two axes I_mean = arr.mean(axis=(-2, -1), keepdims=True) + 1e-30 I_norm = arr / I_mean filtered = xp.real(xp.fft.ifft2(xp.fft.fft2(I_norm) / denom)) phase = xp.log(xp.maximum(filtered, 1e-10)) * (-delta_beta / 2.0) if use_gpu: phase = cp.asnumpy(phase) else: phase = np.asarray(phase) return phase[0] if not stack else phase
[docs] def ctf_retrieve( intensity, distance, wavelength, pixel_size, alpha=1e-3, delta_beta=None, cuda=False, ): """ CTF (Contrast Transfer Function) phase retrieval — single or multi-distance. Linearised phase retrieval valid for a weakly-attenuating, weak-phase object (``|φ| << 1 rad``). Supports both single-distance inversion and **multi-distance / holotomographic** inversion, and both pure-phase and homogeneous (fixed δ/β) objects. For the standard paraxial Fresnel propagator ``H_z = exp(+iπλz|u|²)`` the linearised contrast of a homogeneous object is .. math:: \\hat{H}_d(\\mathbf{u}) = -2\\,w_d(\\mathbf{u})\\,\\hat{\\phi}(\\mathbf{u}), \\qquad w_d = \\sin(\\chi_d) + \\varepsilon \\cos(\\chi_d), with :math:`\\chi_d = \\pi\\lambda z_d |\\mathbf{u}|^2` and :math:`\\varepsilon = \\beta/\\delta = 1/(\\delta/\\beta)` (``ε = 0`` for a pure-phase object). The least-squares Tikhonov inversion combining all distances is .. math:: \\hat{\\phi} = -\\frac{\\sum_d w_d \\hat{H}_d} {2\\left(\\sum_d w_d^2 + \\alpha\\right)} . **Why multi-distance + δ/β recovers low frequencies.** A single distance transfers phase only in a band around the first CTF maximum (:math:`\\chi \\approx \\pi/2`) and is blind at the CTF zeros and at DC (:math:`\\sin\\chi \\to 0`). Using several distances fills the zeros of one with the maxima of another. The absorption term :math:`\\varepsilon\\cos(\\chi_d)` is non-zero at DC (:math:`\\cos 0 = 1`), so a homogeneous object additionally recovers the low frequencies — this is the basis of quantitative holotomography. Parameters ---------- intensity : ndarray Measured intensity image(s), normalised so the vacuum level is 1. Shapes accepted: * ``(M, N)`` — one image, single distance. * ``(K, M, N)`` with a **scalar** ``distance`` — a batch of *K* independent projections, each retrieved separately. * ``(D, M, N)`` with an **array** ``distance`` of length *D* — one object imaged at *D* propagation distances, combined into a single phase map (multi-distance / holotomography). distance : float or sequence of float Propagation distance(s) [m]. A sequence triggers the multi-distance combination; its length must match ``intensity.shape[0]``. wavelength : float X-ray wavelength [m]. pixel_size : float Effective detector pixel size [m]. alpha : float, optional Tikhonov regularisation parameter. Default 1e-3. delta_beta : float or None, optional Ratio δ/β of the complex refractive index. When provided the homogeneous (single-material) model is used, coupling absorption to phase and enabling low-frequency / DC recovery. ``None`` (default) assumes a pure-phase object (``ε = 0``). cuda : bool, optional Run on GPU via CuPy. Falls back to CPU with a warning if CuPy is unavailable. Default False. Returns ------- phase : ndarray Retrieved phase [rad], always a CPU array. Shape ``(M, N)`` for a single image or a multi-distance set, or ``(K, M, N)`` for a batch of independent projections. Examples -------- Single distance, pure phase: >>> import numpy as np >>> from toupy.restoration import ctf_retrieve >>> img = np.ones((256, 256)) >>> phase = ctf_retrieve(img, distance=5e-5, wavelength=7.3e-11, ... pixel_size=5e-8) Multi-distance holotomographic retrieval of a homogeneous object: >>> stack = np.ones((4, 256, 256)) # 4 distances >>> dists = [3e-5, 1.5e-4, 6e-4, 2.4e-3] >>> phase = ctf_retrieve(stack, distance=dists, wavelength=7.3e-11, ... pixel_size=5e-8, delta_beta=50.0) """ xp = _get_xp(cuda) distances = np.atleast_1d(np.asarray(distance, dtype=float)) multi = distances.size > 1 intensity = np.asarray(intensity, dtype=float) if multi: # One object, several distances -> combine into a single phase map. if intensity.ndim != 3 or intensity.shape[0] != distances.size: raise ValueError( "Multi-distance CTF expects intensity of shape " "(D, M, N) with D = len(distance) = {}, got {}.".format( distances.size, intensity.shape) ) batch = False else: # Scalar distance: 2-D -> one image; 3-D -> batch of independent ones. batch = intensity.ndim == 3 if not batch: intensity = intensity[np.newaxis] use_gpu = cuda and CUDA_AVAILABLE arr = xp.asarray(intensity) if use_gpu else intensity ny, nx = arr.shape[-2:] fy, fx = _freq_grid(ny, nx, pixel_size, xp) freq2 = fy**2 + fx**2 eps = 0.0 if delta_beta is None else 1.0 / float(delta_beta) if multi: # Combine distances: φ̂ = -Σ w_d Ĥ_d / (2 (Σ w_d² + α)) num = xp.zeros((ny, nx), dtype=complex) den = xp.zeros((ny, nx)) for d in range(distances.size): chi = np.pi * wavelength * float(distances[d]) * freq2 w = xp.sin(chi) + eps * xp.cos(chi) num = num + w * xp.fft.fft2(arr[d] - 1.0) den = den + w * w phase = -xp.real(xp.fft.ifft2(num / (2.0 * (den + alpha)))) else: # Single distance (optionally a batch of independent projections). # # Sign convention: for H_z = exp(+iπλz|u|²) the contrast is # I - 1 = -2 [sin(χ) + ε cos(χ)] φ # so the inversion carries a leading MINUS sign to return +φ. chi = np.pi * wavelength * float(distances[0]) * freq2 w = xp.sin(chi) + eps * xp.cos(chi) H = xp.fft.fft2(arr - 1.0) phase = -xp.real(xp.fft.ifft2(w * H / (2.0 * (w**2 + alpha)))) if use_gpu: phase = cp.asnumpy(phase) else: phase = np.asarray(phase) if multi: return phase return phase if batch else phase[0]
# --------------------------------------------------------------------------- # Nonlinear iterative phase retrieval (full Fresnel forward model) # ---------------------------------------------------------------------------
[docs] def iterative_phase_retrieval( intensity, distance, wavelength, pixel_size, delta_beta, init=None, n_iter=200, reg_smooth=1e-4, reg_tv=0.0, tv_eps=1e-3, pad=2, tol=1e-7, cuda=False, verbose=False, ): r""" Nonlinear iterative phase retrieval using the exact Fresnel forward model. Refines a phase map by minimising the mismatch between the measured intensity and the intensity predicted by *propagating* a homogeneous-object wavefield — i.e. it inverts the **full** near-field diffraction integral instead of TIE-Hom's first-order (single-fringe) approximation. This makes it accurate in the **multi-fringe** regime (small Fresnel number) and at higher δ/β, where the Paganin/TIE-Hom filter blurs and loses contrast. The object is assumed homogeneous (single material), so absorption and phase are coupled and the unknown is a single real field φ: .. math:: \psi(\phi) = \exp\!\big[(-1/(\delta/\beta) + i)\,\phi\big]. For each propagation distance :math:`z_d` the model intensity is :math:`I_d = |\mathcal{P}_{z_d}\,\psi(\phi)|^2`, where :math:`\mathcal{P}_{z_d}` is the angular-spectrum (Fresnel) propagator. The objective is the amplitude (Gaussian) data term plus a smoothness (Tikhonov) prior .. math:: J(\phi) = \tfrac12 \sum_d \big\|\,|\mathcal{P}_{z_d}\psi(\phi)| - \sqrt{I_d}\,\big\|^2 + \tfrac{\mu}{2}\,\|\nabla\phi\|^2 , minimised by nonlinear conjugate gradient (Polak–Ribière) with an Armijo backtracking line search. The analytic gradient is obtained from the adjoint (back-propagation) of the forward model. Passing **several distances** turns this into *nonlinear holotomography*, which — unlike the linear CTF — remains valid for strong phase (:math:`|\phi| \gtrsim 1` rad) and recovers the low frequencies through the absorption term. Parameters ---------- intensity : ndarray Measured intensity, normalised to unit vacuum level. Shape ``(M, N)`` for a single distance, or ``(D, M, N)`` with a length-*D* sequence ``distance`` for multi-distance retrieval. distance : float or sequence of float Propagation distance(s) [m]. wavelength : float X-ray wavelength [m]. pixel_size : float Effective detector pixel size at the sample [m]. delta_beta : float δ/β ratio of the (homogeneous) object. init : ndarray or None, optional Initial phase guess ``(M, N)``. When ``None`` (default) the TIE-Hom result at the first distance is used — the recommended warm start. n_iter : int, optional Maximum number of conjugate-gradient iterations. Default 200. reg_smooth : float, optional Weight μ of the gradient-squared (Tikhonov) smoothness prior. Set 0 to disable. Default 1e-4. Smooths uniformly (edges included). reg_tv : float, optional Weight of an (ε-smoothed) **total-variation** prior :math:`\sum \sqrt{|\nabla\phi|^2 + \varepsilon^2}`. Unlike the quadratic prior, TV flattens residual ripples / Fresnel artefacts in homogeneous regions while preserving sharp edges — recommended for piecewise-constant samples and for cleaning single-distance results. Set 0 to disable (default). Typical range 1e-3–1e-2. tv_eps : float, optional Smoothing parameter ε of the TV norm (keeps it differentiable for the conjugate-gradient solver). Default 1e-3. pad : int, optional Zero/vacuum padding factor for the propagation FFTs, used to avoid circular wrap-around of the Fresnel kernel. Default 2. tol : float, optional Stop when the relative cost decrease falls below ``tol``. Default 1e-7. cuda : bool, optional Run on GPU via CuPy (falls back to CPU with a warning). Default False. verbose : bool, optional Print the cost every 25 iterations. Default False. Returns ------- phase : ndarray, shape (M, N) Retrieved phase [rad], always a CPU array. Notes ----- * **Phase wrapping.** For a single distance the absolute phase is only well determined up to wrapping; if the true phase greatly exceeds :math:`\pi` the iteration may settle in a wrapped local minimum. Use several distances (and/or a good ``init``) for strong-phase objects. * The object should sit inside the field of view with a vacuum margin so that its propagation fringes are captured by the detector; otherwise the boundary is under-constrained. Examples -------- >>> from toupy.restoration import iterative_phase_retrieval >>> phase = iterative_phase_retrieval(I, distance=8e-4, wavelength=7.3e-11, ... pixel_size=5e-8, delta_beta=20.0) """ xp = _get_xp(cuda) use_gpu = cuda and CUDA_AVAILABLE distances = np.atleast_1d(np.asarray(distance, dtype=float)) intensity = np.asarray(intensity, dtype=float) if distances.size > 1: if intensity.ndim != 3 or intensity.shape[0] != distances.size: raise ValueError( "Multi-distance retrieval expects intensity of shape " "(D, M, N) with D = len(distance) = {}, got {}.".format( distances.size, intensity.shape)) stack = intensity else: stack = intensity[np.newaxis] if intensity.ndim == 2 else intensity ny, nx = stack.shape[-2:] pad = max(1, int(pad)) # Pad only axes with extent > 1 — a size-1 axis (e.g. a single tomographic # projection line) then propagates as an exact 1-D Fresnel problem. my = ny * pad if ny > 1 else 1 mx = nx * pad if nx > 1 else 1 oy, ox = (my - ny) // 2, (mx - nx) // 2 # Warm start: TIE-Hom at the first distance. if init is None: init = tie_hom(stack[0], float(distances[0]), wavelength, pixel_size, delta_beta=delta_beta, cuda=cuda) phi = xp.asarray(np.asarray(init, dtype=float)) sqI = [xp.asarray(np.sqrt(stack[d])) for d in range(distances.size)] # Padded Fresnel propagators, one per distance. fy = xp.fft.fftfreq(my, d=pixel_size) fx = xp.fft.fftfreq(mx, d=pixel_size) FY, FX = xp.meshgrid(fy, fx, indexing="ij") freq2 = FY**2 + FX**2 Hs = [xp.exp(1j * np.pi * wavelength * float(z) * freq2) for z in distances] c = complex(-1.0 / float(delta_beta), 1.0) c_conj = np.conj(c) def _embed(a): b = xp.zeros((my, mx), dtype=a.dtype) b[oy:oy + ny, ox:ox + nx] = a return b def _crop(a): return a[oy:oy + ny, ox:ox + nx] def _cost_grad(phi): psi = xp.exp(c * _embed(phi)) psi_f = xp.fft.fft2(psi) J = 0.0 grad = xp.zeros((ny, nx)) for H, s in zip(Hs, sqI): psz = xp.fft.ifft2(psi_f * H) # propagate amp = xp.abs(psz) res = _crop(amp) - s # amplitude residual J = J + 0.5 * float(xp.sum(res * res)) gz = _embed(res * _crop(psz) / (_crop(amp) + 1e-30)) gobj = xp.fft.ifft2(xp.fft.fft2(gz) * xp.conj(H)) # back-propagate grad = grad + _crop(xp.real(c_conj * xp.conj(psi) * gobj)) if reg_smooth > 0: dphi0 = phi - xp.roll(phi, 1, axis=0) dphi1 = phi - xp.roll(phi, 1, axis=1) J = J + 0.5 * reg_smooth * float(xp.sum(dphi0**2 + dphi1**2)) lap = (-4.0 * phi + xp.roll(phi, 1, 0) + xp.roll(phi, -1, 0) + xp.roll(phi, 1, 1) + xp.roll(phi, -1, 1)) grad = grad - reg_smooth * lap if reg_tv > 0: # Isotropic ε-smoothed TV: Σ sqrt(|∇φ|² + ε²); edge-preserving. dx = xp.roll(phi, -1, axis=1) - phi dy = xp.roll(phi, -1, axis=0) - phi mag = xp.sqrt(dx**2 + dy**2 + tv_eps**2) J = J + reg_tv * float(xp.sum(mag)) px, py = dx / mag, dy / mag divp = (px - xp.roll(px, 1, axis=1)) + (py - xp.roll(py, 1, axis=0)) grad = grad - reg_tv * divp return J, grad # Nonlinear conjugate gradient (Polak–Ribière+) with Armijo backtracking. J, g = _cost_grad(phi) d = -g t = 1.0 for it in range(int(n_iter)): gd = float(xp.sum(g * d)) if gd >= 0: # not a descent direction → restart d = -g gd = float(xp.sum(g * d)) t = min(t * 2.0, 1e3) Jn, gn = _cost_grad(phi + t * d) n_ls = 0 while Jn > J + 1e-4 * t * gd and t > 1e-12 and n_ls < 60: t *= 0.5 Jn, gn = _cost_grad(phi + t * d) n_ls += 1 phi = phi + t * d denom = float(xp.sum(g * g)) + 1e-30 beta = max(0.0, float(xp.sum(gn * (gn - g))) / denom) d = -gn + beta * d rel = (J - Jn) / (abs(J) + 1e-30) g, J = gn, Jn if verbose and (it % 25 == 0 or it == n_iter - 1): print(" iter {:4d} J = {:.6e}".format(it, J)) if 0.0 <= rel < tol: break return cp.asnumpy(phi) if use_gpu else np.asarray(phi)
# --------------------------------------------------------------------------- # Helper: choose a gap-free multi-distance (holotomography) series # ---------------------------------------------------------------------------
[docs] def suggest_holo_distances( pixel_size, wavelength, n=4, delta_beta=None, nf_short=0.5, nf_long=0.01, f_lo=0.03, warn_gap=0.02, return_info=False, ): r""" Suggest a geometrically-spaced set of propagation distances for multi-distance (holotomographic) phase retrieval, and check it is gap-free. The homogeneous phase transfer function at distance :math:`z` is :math:`w_z(u) = \sin(\chi) + \varepsilon\cos(\chi)` with :math:`\chi = \pi\lambda z u^2` and :math:`\varepsilon = 1/(\delta/\beta)`. A single distance loses information at its CTF zeros :math:`u_n = \sqrt{n/(\lambda z)}`. Several distances fill those gaps when their zeros **interleave**, which happens for a geometric spacing of *z*. The series is built directly from the two Fresnel-number end points: * the **shortest** distance has :math:`N_F = N_{F,\mathrm{short}}` (:math:`N_F = \mathrm{pixel}^2/(\lambda z)`). Keep ``nf_short ≳ 0.25`` so its first zero sits at/beyond Nyquist — a smooth, zero-free backbone that ensures no frequency is entirely lost; * the **longest** distance has :math:`N_F = N_{F,\mathrm{long}}`. Smaller ``nf_long`` (longer z) improves the low-frequency / quantitative DC transfer; it is bounded in practice by coherence, detector blur, geometry and the Fresnel sampling limit :math:`z < N\,\mathrm{pixel}^2/\lambda`; * the intermediate distances are spaced **geometrically** :math:`z_d = z_0\,R^{\,d}`, :math:`R=(z_{n-1}/z_0)^{1/(n-1)}`, which makes the CTF zeros interleave evenly. The worst-case combined transfer :math:`\sqrt{\langle w_z^2\rangle}` over ``f_lo·u_Nyq ≤ u ≤ u_Nyq`` is evaluated; a warning is issued if it falls below ``warn_gap`` (a residual gap). Parameters ---------- pixel_size : float Effective detector pixel size at the sample [m]. wavelength : float X-ray wavelength [m]. n : int, optional Number of distances. Default 4. delta_beta : float or None, optional δ/β of the object. Includes the absorption term in the transfer function (improves low-frequency coverage). ``None`` → pure phase. nf_short : float, optional Fresnel number of the **shortest** distance. Default 0.5 (zero-free, with margin above the 0.25 limit). nf_long : float, optional Fresnel number of the **longest** distance. Default 0.01. f_lo : float, optional Lowest frequency (fraction of Nyquist) at which coverage is required; below it transfer is intrinsically weak (→ ε at DC). Default 0.03. warn_gap : float, optional Warn if the worst-case combined transfer drops below this. Default 0.02. return_info : bool, optional If True, also return a diagnostics dict. Default False. Returns ------- distances : ndarray, shape (n,) Suggested propagation distances [m], ascending. info : dict, optional Present when ``return_info=True``: ``ratio`` *R*, ``min_coverage`` (worst combined transfer; larger is better, ~0 means a gap), ``NF`` (per-distance Fresnel numbers). Examples -------- >>> from toupy.restoration import suggest_holo_distances, iterative_phase_retrieval >>> z = suggest_holo_distances(50e-9, 7.3e-11, n=4, delta_beta=20) >>> # ... acquire/simulate the intensity stack at these distances ... >>> # phase = iterative_phase_retrieval(stack, z, 7.3e-11, 5e-8, delta_beta=20) """ n = int(n) if nf_long >= nf_short: raise ValueError("nf_long ({}) must be < nf_short ({}) — the longest " "distance has the smaller Fresnel number." .format(nf_long, nf_short)) eps = 0.0 if delta_beta is None else 1.0 / float(delta_beta) u_nyq = 1.0 / (2.0 * pixel_size) z_short = pixel_size**2 / (wavelength * nf_short) z_long = pixel_size**2 / (wavelength * nf_long) if n <= 1: distances = np.array([z_short]) ratio = float("nan") else: ratio = (z_long / z_short) ** (1.0 / (n - 1)) distances = z_short * ratio ** np.arange(n) distances = np.asarray(distances, dtype=float) # Worst-case combined transfer over the requested band. u = np.linspace(f_lo * u_nyq, u_nyq, 1400) acc = np.zeros_like(u) for z in distances: chi = np.pi * wavelength * z * u**2 acc = acc + (np.sin(chi) + eps * np.cos(chi)) ** 2 min_cov = float(np.sqrt(acc / n).min()) if min_cov < warn_gap: warnings.warn( "Suggested distances leave a residual frequency gap " "(min combined transfer {:.3f} < {:.3f}). Consider more distances " "or a wider nf_short/nf_long span.".format(min_cov, warn_gap), stacklevel=2, ) if return_info: info = { "ratio": ratio, "min_coverage": min_cov, "NF": [pixel_size**2 / (wavelength * z) for z in distances], } return distances, info return distances