Source code for prtools.zernike

from math import factorial

import numpy as np

import prtools


[docs] def zernike(mask, index, normalize=True, rho=None, theta=None): """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 Noll Zernike index as defined in [1] normalize : bool, optional If True (default), the output is normalized according to [1]. If False, the output value ranges [-1, 1] over the mask. 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. Returns ------- ndarray 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). """ mask = np.asarray(mask) #out = np.zeros_like(mask) #mask_slice = util.boundary_slice(mask, pad=0) #mask = mask[mask_slice] if rho is None: rho, theta = zernike_coordinates(mask) else: if theta is None: raise ValueError("Both rho and theta must be specified") m, n = zernike_index(index) if m == 0: if n == 0: Z = mask 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 normalize: Z = np.sqrt(2) * np.sqrt(n+1) * R(m, n, rho) * np.sin(m*theta) * mask else: Z = R(m, n, rho) * np.sin(m*theta) * mask #out[mask_slice] = Z 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, rho=None, theta=None): """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 (Noll ordering) 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. 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. Returns ------- ndarray 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). """ mask = np.asarray(mask) coeffs = np.asarray(coeffs) opd = np.zeros(mask.shape) for index, coeff in np.ndenumerate(coeffs): opd += coeff * zernike(mask, index[0]+1, normalize, rho, theta) return opd
[docs] def zernike_basis(mask, modes, vectorize=False, normalize=True, rho=None, theta=None): """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 (Noll ordering) 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. 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. Returns ------- ndarray References ---------- [1] Noll, RJ. Zernike polynomials and atmospheric turbulence. J Opt Soc Am 66, 207-211 (1976). """ modes = np.asarray(modes) if modes.shape == (): modes = modes[..., np.newaxis] mask = np.asarray(mask) basis = np.zeros(modes.shape + mask.shape) for index, mode in np.ndenumerate(modes): basis[index] = zernike(mask, mode, normalize, rho, theta) 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, rho=None, theta=None): """Fit a Zernike basis set to 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 (Noll ordering) 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. 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. Returns ------- ndarray 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). """ opd = np.asarray(opd) mask = np.asarray(mask) basis = zernike_basis(mask, modes, True, normalize, rho, theta) basis = np.linalg.pinv(basis) return np.einsum('ij,i->j', basis, opd.ravel())
[docs] def zernike_remove(opd, mask, modes, rho=None, theta=None): """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 (Noll ordering) to remove. 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. Returns ------- ndarray See Also -------- :func:`zernike_fit` References ---------- [1] Noll, RJ. Zernike polynomials and atmospheric turbulence. J Opt Soc Am 66, 207-211 (1976). """ opd = np.asarray(opd) mask = np.asarray(mask) coeffs = zernike_fit(opd, mask, modes, rho, theta) fit_opd = zernike_compose(mask, coeffs, rho, theta) residual = opd - fit_opd return residual
def zernike_index(j): """Convert a 1-D Noll index to a 2-D radiul and azimuthal index. Parameters ---------- j : int Noll Zernike index. Returns ------- n : int Radial Zernike index. m : int Azimuthal Zernike index. """ if j < 1: raise ValueError('Zernike index j must be a positive integer') # 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 i in range(int(np.floor(n / 2))): row_m.append(row_m[-1] + 2) row_m.append(row_m[-1]) m = row_m[r] * sign return m, n
[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. shift : (2,) array_like, optional How far to shift center in float (rows, cols). 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. rotate is specified relative to the x-axis. Default is 0. indexing : {'ij', 'xy'}, optional Matrix ('ij', default) or cartesian ('xy') indexing of output. Returns ------- rho, theta : ndarrays """ mask = np.asarray(mask) if shift is None: center = np.asarray(mask.shape)/2 centroid = prtools.centroid(mask) shift = (centroid[0]-center[0], centroid[1]-center[1]) yy, xx = prtools.mesh(shape=mask.shape, shift=shift, angle=angle, indexing=indexing) r = np.abs(xx+1j*yy) rho = r/np.max(r*mask) # rho is defined to be 1 on the edge of the aperture rho[np.where(rho==0)] = 1e-99 theta = np.angle(xx + 1j*yy) theta[np.where(theta==0)] = 1e-99 return rho, theta