from math import factorial
import warnings
import numpy as np
import prtools
def _warn_legacy(legacy):
# Called once at the top of each public function. stacklevel=3 skips this
# helper and the public function so the warning points at the caller.
if legacy:
warnings.warn(
'legacy=True reproduces the Zernike orientation convention of '
'prtools 2.0.2 and earlier. Support for it may be removed in a '
'future release.',
FutureWarning,
stacklevel=3
)
[docs]
def zernike(mask, index, normalize=True, order='noll', rho=None, theta=None,
legacy=False):
"""Compute the circular Zernike polynomial for a given mask.
Parameters
----------
mask : array_like
Binary mask defining the extent to compute the Zernike polynomial
over.
index : int
Zernike mode index
normalize : bool, optional
If True (default), the output is normalized according to [1]. If False,
the output value ranges [-1, 1] over the mask.
order : {'noll', 'fringe', 'ansi'}, optional
Zernike ordering scheme. Default is 'noll'.
rho : array_like, optional
Radial coordinates of the mask array. :attr:`rho` should be 0 at the
origin and 1 at the edge of the circle.
theta : array_like, optional
Angular coordinates of the mask array in radians.
legacy : bool, optional
If True, reproduce the Zernike convention of prtools 2.0.2 and
earlier. Default is False. See the warning below for details.
Returns
-------
ndarray
Warnings
--------
prtools 2.0.2 and earlier computed Zernike polynomials in a coordinate
system mirrored top-to-bottom (y -> -y). This results in modes with
negative azimuthal order (sine terms: y tilt, 45 degree astigmatism, y
coma, ...) having a flipped sign. Modes with zero or positive azimuthal
order are unaffected. The :func:`zernike_coeff_legacy` function converts
between current and legacy conventions.
Notes
-----
Zernike polynomials are defined to be orthogonal on the unit circle. If
the supplied mask is non-circular, the Zernike polynomial is computed on an
outscribing circle and then cropped by the mask. Note that this operation
breaks the orthogonality of the Zernike polynomial. When working with a
non-circular mask, care must be taken to understand any side-effects of
using Zernike polynomials constructed in this manner. Any undesirable
side-effects can be mitigated by using Zernike polynomials that have
undergone Gram-Schmidt orthogonalization over the supplied mask.
References
----------
[1] Noll, RJ. Zernike polynomials and atmospheric turbulence. J Opt Soc Am 66, 207-211 (1976).
"""
_warn_legacy(legacy)
return _zernike(mask, index, normalize, order, rho, theta, legacy)
def _zernike(mask, index, normalize, order, rho, theta, legacy):
mask = np.asarray(mask, dtype=bool)
if order == 'noll':
n, m = noll_to_nm(index)
elif order == 'fringe':
n, m = fringe_to_nm(index)
elif order == 'ansi':
n, m = ansi_to_nm(index)
else:
raise ValueError('Unknown order', order)
if rho is None:
if legacy:
rho, theta = _zernike_coordinates_legacy(mask)
else:
rho, theta = zernike_coordinates(mask)
else:
if theta is None:
raise ValueError("Both rho and theta must be specified")
if m == 0:
if n == 0:
Z = mask.astype(float)
else:
if normalize:
Z = np.sqrt(n+1) * R(m, n, rho) * mask
else:
Z = R(m, n, rho) * mask
elif m > 0:
if normalize:
Z = np.sqrt(2) * np.sqrt(n+1) * R(m, n, rho) * np.cos(m*theta) * mask
else:
Z = R(m, n, rho) * np.cos(m*theta) * mask
else:
if legacy:
sin_m_theta = np.sin(m*theta)
else:
sin_m_theta = np.sin(np.abs(m)*theta)
if normalize:
Z = np.sqrt(2) * np.sqrt(n+1) * R(m, n, rho) * sin_m_theta * mask
else:
Z = R(m, n, rho) * sin_m_theta * mask
out = Z
return out
def R(m, n, rho):
m = int(np.abs(m))
n = int(np.abs(n))
if (n - m) & 1: # odd
return 0
else:
R = np.zeros(rho.shape)
for k in range(int(n-m)//2 + 1):
Rk = ((-1) ** k * factorial(n-k) /
(factorial(k) * factorial((n+m)//2-k) * factorial((n-m)//2-k)))
R += Rk * rho ** (n-2*k)
return R
[docs]
def zernike_compose(mask, coeffs, normalize=True, order='noll', rho=None,
theta=None, legacy=False):
"""Create an OPD based on the supplied Zernike coefficients.
Parameters
----------
mask : array_like
Binary mask defining the extent to compute the Zernike polynomial over.
coeffs : array_like
List of coefficients corresponding to Zernike indices used to create
the OPD.
normalize : bool, optional
If True (default), the output is normalized according to [1]. If False,
the output value ranges [-1, 1] over the mask.
order : {'noll', 'fringe', 'ansi'}, optional
Zernike ordering scheme. Default is 'noll'.
rho : array_like, optional
Radial coordinates of the mask array. :attr:`rho` should be 0 at the
origin and 1 at the edge of the circle.
theta : array_like, optional
Angular coordinates of the mask array in radians.
legacy : bool, optional
If True, reproduce the Zernike convention of prtools 2.0.2 and
earlier. Default is False. See the warning below for details.
Returns
-------
ndarray
Warnings
--------
prtools 2.0.2 and earlier computed Zernike polynomials in a coordinate
system mirrored top-to-bottom (y -> -y). This results in modes with
negative azimuthal order (sine terms: y tilt, 45 degree astigmatism, y
coma, ...) having a flipped sign. Modes with zero or positive azimuthal
order are unaffected. The :func:`zernike_coeff_legacy` function converts
between current and legacy conventions.
Examples
--------
Compute a random OPD using the first ten Zernikes:
.. plot::
:scale: 50
:include-source:
:context: reset
>>> mask = prtools.circle((256,256), 120, antialias=False)
>>> coeffs = np.random.rand(10)*1e-8
>>> opd = prtools.zernike_compose(mask, coeffs)
>>> plt.imshow(opd)
Using the same mask, compute an OPD representing 200 nm focus error (Z4) and -100 nm
astigmatism error (Z6):
.. plot::
:context: close-figs
:include-source:
:scale: 50
>>> opd = prtools.zernike_compose(mask, [0, 0, 0, 200e-9, 0, -100e-9])
>>> plt.imshow(opd)
References
----------
[1] Noll, RJ. Zernike polynomials and atmospheric turbulence. J Opt Soc Am 66, 207-211 (1976).
"""
_warn_legacy(legacy)
return _zernike_compose(mask, coeffs, normalize, order, rho, theta, legacy)
def _zernike_compose(mask, coeffs, normalize, order, rho, theta, legacy):
mask = np.asarray(mask)
coeffs = np.asarray(coeffs)
opd = np.zeros(mask.shape)
for index, coeff in enumerate(coeffs):
opd += coeff * _zernike(mask, index+1, normalize, order, rho, theta,
legacy)
return opd
[docs]
def zernike_basis(mask, modes, vectorize=False, normalize=True, order='noll',
rho=None, theta=None, legacy=False):
"""Compute a Zernike basis set for a given mask.
Parameters
----------
mask : array_like
Binary mask defining the extent to compute the Zernike polynomial over.
modes : array_like
List of modes to return.
vectorize : bool, optional
If True, the output is returned as a
``(length(modes), modes.shape[0]*modes.shape[1])`` array If False
(default), the output is returned as a
``(length(terms), mask.shape[0], mask.shape[1])`` cube.
normalize : bool, optional
If True (default), the output is normalized according to [1]. If False,
the output value ranges [-1, 1] over the mask.
order : {'noll', 'fringe', 'ansi'}, optional
Zernike ordering scheme. Default is 'noll'.
rho : array_like, optional
Radial coordinates of the mask array. :attr:`rho` should be 0 at the
origin and 1 at the edge of the circle.
theta : array_like, optional
Angular coordinates of the mask array in radians.
legacy : bool, optional
If True, reproduce the Zernike convention of prtools 2.0.2 and
earlier. Default is False. See the warning below for details.
Returns
-------
ndarray
Warnings
--------
prtools 2.0.2 and earlier computed Zernike polynomials in a coordinate
system mirrored top-to-bottom (y -> -y). This results in modes with
negative azimuthal order (sine terms: y tilt, 45 degree astigmatism, y
coma, ...) having a flipped sign. Modes with zero or positive azimuthal
order are unaffected. The :func:`zernike_coeff_legacy` function converts
between current and legacy conventions.
References
----------
[1] Noll, RJ. Zernike polynomials and atmospheric turbulence. J Opt Soc Am 66, 207-211 (1976).
"""
_warn_legacy(legacy)
return _zernike_basis(mask, modes, vectorize, normalize, order, rho, theta,
legacy)
def _zernike_basis(mask, modes, vectorize, normalize, order, rho, theta,
legacy):
modes = np.asarray(modes)
if modes.shape == ():
modes = modes[..., np.newaxis]
mask = np.asarray(mask)
basis = []
for index, mode in enumerate(modes):
basis.append(_zernike(mask, mode, normalize, order, rho, theta,
legacy))
basis = np.asarray(basis)
if vectorize:
# reshape basis from cube to matrix
return basis.reshape(basis.shape[0], -1)
else:
return basis
[docs]
def zernike_fit(opd, mask, modes, normalize=True, order='noll', rho=None,
theta=None, legacy=False):
"""Fit a Zernike basis set to an OPD.
Parameters
----------
opd : array_like
OPD to fit. NaN values in ``opd`` are excluded from the fit.
mask : array_like
Binary mask defining the extent to compute the Zernike basis over.
modes : array_like
List of modes to fit.
normalize : bool, optional
If True (default), the output is normalized according to [1]. If False,
the output value ranges [-1, 1] over the mask.
order : {'noll', 'fringe', 'ansi'}, optional
Zernike ordering scheme. Default is 'noll'.
rho : array_like, optional
Radial coordinates of the mask array. :attr:`rho` should be 0 at the
origin and 1 at the edge of the circle.
theta : array_like, optional
Angular coordinates of the mask array in radians.
legacy : bool, optional
If True, reproduce the Zernike convention of prtools 2.0.2 and
earlier. Default is False. See the warning below for details.
Returns
-------
ndarray
Warnings
--------
prtools 2.0.2 and earlier computed Zernike polynomials in a coordinate
system mirrored top-to-bottom (y -> -y). This results in modes with
negative azimuthal order (sine terms: y tilt, 45 degree astigmatism, y
coma, ...) having a flipped sign. Modes with zero or positive azimuthal
order are unaffected. The :func:`zernike_coeff_legacy` function converts
between current and legacy conventions.
Example
-------
.. code:: pycon
>>> mask = prtools.circle((256,256),128)
>>> coeffs = np.random.rand(4)*100e-9
>>> opd = prtools.zernike_compose(mask, coeffs)
>>> fit_coeffs = prtools.zernike_fit(opd, mask, np.arange(2,4))
>>> print('Tip/tilt coefficients:', coeffs[1:3])
>>> print('Fit tip/tilt coefficients:', fit_coeffs)
Tip/tilt coefficients: [9.69097470e-08 9.94332699e-08]
Fit tip/tilt coefficients: [9.69545890e-08 9.94781119e-08]
See Also
--------
:func:`zernike_remove` Fit and remove a Zernike basis set from an OPD.
References
----------
[1] Noll, RJ. Zernike polynomials and atmospheric turbulence. J Opt Soc Am 66, 207-211 (1976).
"""
_warn_legacy(legacy)
return _zernike_fit(opd, mask, modes, normalize, order, rho, theta, legacy)
def _zernike_fit(opd, mask, modes, normalize, order, rho, theta, legacy):
opd = np.asarray(opd).ravel()
mask = np.asarray(mask)
basis = _zernike_basis(mask, modes, True, normalize, order, rho, theta,
legacy)
# valid data includes all non-NaN values inside the mask
valid = np.isfinite(opd) & mask.ravel().astype(bool)
coeff, *_ = np.linalg.lstsq(basis[:, valid].T, opd[valid], rcond=None)
return coeff
[docs]
def zernike_remove(opd, mask, modes, order='noll', rho=None, theta=None,
legacy=False):
"""Fit and remove a Zernike basis set from an OPD.
Parameters
----------
opd : array_like
OPD to fit.
mask : array_like
Binary mask defining the extent to compute the Zernike basis over.
modes : array_like
List of modes to remove.
order : {'noll', 'fringe', 'ansi'}, optional
Zernike ordering scheme. Default is 'noll'.
rho : array_like, optional
Radial coordinates of the mask array. :attr:`rho` should be 0 at the
origin and 1 at the edge of the circle.
theta : array_like, optional
Angular coordinates of the mask array in radians.
legacy : bool, optional
If True, reproduce the Zernike convention of prtools 2.0.2 and
earlier. Default is False. See the warning below for details.
Returns
-------
ndarray
Warnings
--------
prtools 2.0.2 and earlier computed Zernike polynomials in a coordinate
system mirrored top-to-bottom (y -> -y). This results in modes with
negative azimuthal order (sine terms: y tilt, 45 degree astigmatism, y
coma, ...) having a flipped sign. Modes with zero or positive azimuthal
order are unaffected. The :func:`zernike_coeff_legacy` function converts
between current and legacy conventions.
See Also
--------
:func:`zernike_fit`
References
----------
[1] Noll, RJ. Zernike polynomials and atmospheric turbulence. J Opt Soc Am 66, 207-211 (1976).
"""
_warn_legacy(legacy)
opd = np.asarray(opd)
mask = np.asarray(mask)
coeffs = _zernike_fit(opd, mask, modes, True, order, rho, theta, legacy)
fit_opd = _zernike_compose(mask, coeffs, True, order, rho, theta, legacy)
residual = opd - fit_opd
return residual
def noll_to_nm(j):
"""Convert Noll index to (n, m) index.
Parameters
----------
j : int
Noll Zernike index
Returns
-------
n : int
Radial degree.
m : int
Azimuthal degree.
"""
if j < 1:
raise ValueError('Noll index must be greater than zero')
# find the row j is in
n = int(np.ceil((-1 + np.sqrt(1 + 8 * j)) / 2) - 1)
if n == 0:
m = 0
else:
# find where in the row j is
k = (n + 1) * (n + 2) / 2
r = int(j - k - 1)
if j & 1: # odd
sign = -1
else:
sign = 1
if n & 1: # odd
row_m = [1, 1]
else:
row_m = [0]
for _ in range(n // 2):
row_m.append(row_m[-1] + 2)
row_m.append(row_m[-1])
m = row_m[r] * sign
return int(n), int(m)
def ansi_to_nm(j):
"""Convert ANSI index to (n, m) index.
Parameters
----------
j : int
ANSI index
Returns
-------
n : int
Radial degree
m : int
Azimuthal degree
"""
if j < 0:
raise ValueError('ANSI index must be zero or larger')
# https://en.wikipedia.org/wiki/Zernike_polynomials
n = np.floor((np.sqrt(8 * j + 1) - 1) / 2)
m = 2*j - n*(n + 2)
return int(n), int(m)
def fringe_to_nm(j):
"""Convert Fringe index to (n, m) index.
Parameters
----------
j : int
Fringe index
Returns
-------
n : int
Radial degree
m : int
Azimuthal degree
"""
if j < 1:
raise ValueError('Fringe index must be greater than zero')
# https://github.com/arielmission-space/PAOS/blob/main/paos/classes/zernike.py
m_n = 2 * (np.ceil(np.sqrt(j)) - 1)
g_s = (m_n / 2) ** 2 + 1
n = m_n / 2 + np.floor((j - g_s) / 2)
m = (m_n - n) * (1 - np.mod(j - g_s, 2) * 2)
return int(n), int(m)
[docs]
def zernike_coordinates(mask, shift=None, angle=0, indexing='ij'):
"""Compute the Zernike coordinate system for a given mask.
Parameters
----------
mask : array_like
Binary mask defining the extent to compute the Zernike polynomial
over. Nonzero values are treated as inside the mask.
shift : (2,) array_like, optional
How far to shift the origin from the center of the array. If None
(default), shift is computed automatically to locate the origin at the
mask centroid.
angle: float, optional
Angle to rotate coordinate system by in degrees, from the +x axis
toward the +y axis. Default is 0.
indexing : {'ij', 'xy'}, optional
Matrix ('ij', default) or cartesian ('xy') indexing of ``shift``.
Returns
-------
rho, theta : ndarrays
Normalized radius and angle. theta is measured from +x (increasing
column) toward +y (increasing row).
"""
mask = np.asarray(mask, dtype=bool)
if shift is None:
shift = prtools.centroid(mask, mode='center', indexing='xy')
elif indexing == 'ij':
shift = np.flip(shift)
# an xy rotation from +x toward +y is a negative ij rotation
x1, x2 = prtools.mesh(shape=mask.shape, shift=shift, angle=angle,
indexing='xy')
r = np.hypot(x1, x2)
rho = r/np.max(r*mask) # rho is defined to be 1 on the edge of the aperture
theta = np.arctan2(x2, x1)
rho[np.where(rho == 0)] = 1e-99
theta[np.where(theta == 0)] = 1e-99
return rho, theta
def _zernike_coordinates_legacy(mask):
# Reproduces zernike_coordinates(mask) from prtools 2.0.2 and earlier:
# +y toward decreasing row (theta is measured toward increasing row and
# paired with sin(m*theta) for m < 0), and the origin measured from
# shape/2 rather than the mesh origin at floor(shape/2)
mask = np.asarray(mask, dtype=bool)
center = np.asarray(mask.shape)/2
centroid = prtools.centroid(mask)
shift = (centroid[0]-center[0], centroid[1]-center[1])
rr, cc = prtools.mesh(shape=mask.shape, shift=shift, indexing='ij')
r = np.abs(cc + 1j*rr)
rho = r/np.max(r*mask)
theta = np.angle(cc + 1j*rr)
rho[np.where(rho==0)] = 1e-99
theta[np.where(theta==0)] = 1e-99
return rho, theta
[docs]
def zernike_coeff_legacy(coeffs, modes=None, order='noll'):
"""Convert Zernike coefficients between legacy and current
orientation conventions.
prtools 2.0.2 and earlier computed Zernike polynomials in a coordinate
system mirrored top-to-bottom (y -> -y). This results in modes with
negative azimuthal order (sine terms: y tilt, 45 degree astigmatism, y
coma, ...) having a flipped sign. Modes with zero or positive azimuthal
order are unaffected.
This conversion is involutory so it converts legacy coefficients to the
current convention and current coefficients to the legacy convention.
Parameters
----------
coeffs : array_like
Zernike coefficients. The last axis indexes Zernike modes, so a
stack of coefficient vectors (e.g. a time series) can be converted
in a single call.
modes : array_like, optional
Zernike mode indices corresponding to the last axis of ``coeffs``,
e.g. the ``modes`` passed to :func:`zernike_fit`. If None
(default), ``coeffs[..., k]`` is taken to be the coefficient for
mode index ``k + 1``, matching :func:`zernike_compose`.
order : {'noll', 'fringe', 'ansi'}, optional
Zernike ordering scheme. Default is 'noll'.
Returns
-------
ndarray
Converted coefficients with the same shape as ``coeffs``.
Notes
-----
The conversion is exact for masks with an even number of pixels in each
dimension. Along any dimension with an odd number of pixels, the legacy
coordinate origin was also offset half a pixel from the mask centroid.
The effect of that offset on fitted coefficients is small but is not a
sign change, so it cannot be undone by this function.
Examples
--------
Convert tip, tilt, and focus coefficients fit with prtools 2.0.2:
.. code:: pycon
>>> prtools.zernike_coeff_legacy([1e-8, 2e-8, 3e-8], modes=[2, 3, 4])
array([ 1.e-08, -2.e-08, 3.e-08])
See Also
--------
:func:`zernike_compose`
:func:`zernike_fit`
"""
coeffs = np.asarray(coeffs)
if coeffs.ndim == 0:
raise ValueError('coeffs must have at least one dimension')
if order == 'noll':
to_nm = noll_to_nm
elif order == 'fringe':
to_nm = fringe_to_nm
elif order == 'ansi':
to_nm = ansi_to_nm
else:
raise ValueError('Unknown order', order)
if modes is None:
modes = np.arange(1, coeffs.shape[-1] + 1)
else:
modes = np.atleast_1d(np.asarray(modes))
if modes.ndim != 1 or modes.shape[0] != coeffs.shape[-1]:
raise ValueError(
f'modes has {modes.size} entries but the last axis of '
f'coeffs has length {coeffs.shape[-1]}')
sign = np.array([-1 if to_nm(int(j))[1] < 0 else 1 for j in modes])
return coeffs * sign