Source code for prysm.psf

"""A base point spread function interface."""
from scipy import optimize

from matplotlib import colors, patches

from .coordinates import cart_to_polar
from .util import share_fig_ax, sort_xy
from .convolution import Convolvable
from .propagation import (
    prop_pupil_plane_to_psf_plane,
    prop_pupil_plane_to_psf_plane_units,
)

from prysm import mathops as m


FIRST_AIRY_ENCIRCLED = 0.8377850436212378


[docs]class PSF(Convolvable): """A Point Spread Function. Attributes ---------- center_x : `int` center sample along x center_y : `int` center sample along y data : `numpy.ndarray` PSF normalized intensity data sample_spacing : `float` center to center spacing of samples unit_x : `numpy.ndarray` x Cartesian axis locations of samples, 1D ndarray unit_y `numpy.ndarray` y Cartesian axis locations of samples, 1D ndarray """ def __init__(self, data, unit_x, unit_y): """Create a PSF object. Parameters ---------- data : `numpy.ndarray` intensity data for the PSF unit_x : `numpy.ndarray` 1D ndarray defining x data grid unit_y : `numpy.ndarray` 1D ndarray defining y data grid sample_spacing : `float` center-to-center spacing of samples, expressed in microns """ super().__init__(data, unit_x, unit_y, has_analytic_ft=False) self.data /= self.data.max() self._ee = {} self._mtf = None self._nu_p = None self._dnx = None self._dny = None
[docs] def encircled_energy(self, radius): """Compute the encircled energy of the PSF. Parameters ---------- radius : `float` or iterable radius or radii to evaluate encircled energy at Returns ------- encircled energy if radius is a float, returns a float, else returns a list. Notes ----- implementation of "Simplified Method for Calculating Encircled Energy," Baliga, J. V. and Cohn, B. D., doi: 10.1117/12.944334 """ from .otf import MTF if hasattr(radius, '__iter__'): # user wants multiple points # um to mm, cy/mm assumed in Fourier plane radius_is_array = True else: radius_is_array = False # compute MTF from the PSF if self._mtf is None: self._mtf = MTF.from_psf(self) nx, ny = m.meshgrid(self._mtf.unit_x, self._mtf.unit_y) self._nu_p = m.sqrt(nx ** 2 + ny ** 2) self._dnx, self._dny = ny[1, 0] - ny[0, 0], nx[0, 1] - nx[0, 0] if radius_is_array: out = [] for r in radius: if r not in self._ee: self._ee[r] = _encircled_energy_core(self._mtf.data, r / 1e3, self._nu_p, self._dnx, self._dny) out.append(self._ee[r]) return m.asarray(out) else: if radius not in self._ee: self._ee[radius] = _encircled_energy_core(self._mtf.data, radius / 1e3, self._nu_p, self._dnx, self._dny) return self._ee[radius]
[docs] def ee_radius(self, energy=FIRST_AIRY_ENCIRCLED): k, v = list(self._ee.keys()), list(self._ee.values()) if energy in v: idx = v.index(energy) return k[idx] def optfcn(x): return abs(self.encircled_energy(x) - energy) # golden seems to perform best in presence of shallow local minima as in # the encircled energy return optimize.golden(optfcn)
[docs] def ee_radius_diffraction(self, energy=FIRST_AIRY_ENCIRCLED): return _inverse_analytic_encircled_energy(self.fno, self.wavelength, energy)
[docs] def ee_radius_ratio_to_diffraction(self, energy=FIRST_AIRY_ENCIRCLED): self_rad = self.ee_radius(energy) diff_rad = _inverse_analytic_encircled_energy(self.fno, self.wavelength, energy) return self_rad / diff_rad
# plotting -----------------------------------------------------------------
[docs] def plot2d(self, axlim=25, power=1, clim=(None, None), interp_method='lanczos', pix_grid=None, invert=False, fig=None, ax=None, show_axlabels=True, show_colorbar=True, circle_ee=None, circle_ee_lw=None): """Create a 2D plot of the PSF. Parameters ---------- axlim : `float` limits of axis, symmetric. xlim=(-axlim,axlim), ylim=(-axlim, axlim) power : `float` power to stretch the data by for plotting clim : iterable limits to use for log color scaling. If power != 1 and clim != (None, None), clim (log axes) takes precedence interp_method : `string` method used to interpolate the image between samples of the PSF pix_grid : `float` if not None, overlays gridlines with spacing equal to pix_grid. Intended to show the collection into camera pixels while still in the oversampled domain invert : `bool`, optional whether to invert the color scale fig : `matplotlib.figure.Figure`, optional: Figure containing the plot ax : `matplotlib.axes.Axis`, optional: Axis containing the plot show_axlabels : `bool` whether or not to show the axis labels show_colorbar : `bool` whether or not to show the colorbar circle_ee : `float`, optional relative encircled energy to draw a circle at, in addition to diffraction limited airy radius (1.22*λ*F#). First airy zero occurs at circle_ee=0.8377850436212378 circle_ee_lw : `float`, optional linewidth passed to matplotlib for the encircled energy circles Returns ------- fig : `matplotlib.figure.Figure`, optional Figure containing the plot ax : `matplotlib.axes.Axis`, optional Axis containing the plot """ label_str = 'Normalized Intensity [a.u.]' left, right = self.unit_x[0], self.unit_x[-1] bottom, top = self.unit_y[0], self.unit_y[-1] if invert: cmap = 'Greys' else: cmap = 'Greys_r' fig, ax = share_fig_ax(fig, ax) plt_opts = { 'extent': [left, right, bottom, top], 'origin': 'lower', 'cmap': cmap, 'interpolation': interp_method, } cb_opts = {} if power is not 1: plt_opts['norm'] = colors.PowerNorm(1/power) plt_opts['clim'] = (0, 1) elif clim[1] is not None: plt_opts['norm'] = colors.LogNorm(*clim) cb_opts = {'extend': 'both'} im = ax.imshow(self.data, **plt_opts) if show_colorbar: cb = fig.colorbar(im, label=label_str, ax=ax, fraction=0.046, **cb_opts) cb.outline.set_edgecolor('k') cb.outline.set_linewidth(0.5) if show_axlabels: ax.set(xlabel=r'Image Plane $x$ [$\mu m$]', ylabel=r'Image Plane $y$ [$\mu m$]') ax.set(xlim=(-axlim, axlim), ylim=(-axlim, axlim)) if pix_grid is not None: # if pixel grid is desired, add it mult = m.floor(axlim / pix_grid) gmin, gmax = -mult * pix_grid, mult * pix_grid pts = m.arange(gmin, gmax, pix_grid) ax.set_yticks(pts, minor=True) ax.set_xticks(pts, minor=True) ax.yaxis.grid(True, which='minor', color='white', alpha=0.25) ax.xaxis.grid(True, which='minor', color='white', alpha=0.25) if circle_ee is not None: if self.fno is None: raise ValueError('F/# must be known to compute EE, set self.fno') elif self.wavelength is None: raise ValueError('wavelength must be known to compute EE, set self.wavelength') radius = self.ee_radius(circle_ee) analytic = _inverse_analytic_encircled_energy(self.fno, self.wavelength, circle_ee) c_diff = patches.Circle((0, 0), analytic, fill=False, color='r', ls='--', lw=circle_ee_lw) c_true = patches.Circle((0, 0), radius, fill=False, color='r', lw=circle_ee_lw) ax.add_artist(c_diff) ax.add_artist(c_true) ax.legend([c_diff, c_true], ['Diff. Lim.', 'Actual'], ncol=2) return fig, ax
[docs] def plot_encircled_energy(self, axlim=None, npts=50, lw=3, fig=None, ax=None): """Make a 1D plot of the encircled energy at the given azimuth. Parameters ---------- azimuth : `float` azimuth to plot at, in degrees axlim : `float` limits of axis, will plot [0, axlim] npts : `int`, optional number of points to use from [0, axlim] lw : `float`, optional linewidth provided directly to matplotlib fig : `matplotlib.figure.Figure`, optional Figure containing the plot ax : `matplotlib.axes.Axis`, optional: Axis containing the plot Returns ------- fig : `matplotlib.figure.Figure`, optional Figure containing the plot ax : `matplotlib.axes.Axis`, optional: Axis containing the plot """ if axlim is None: if len(self._ee) is not 0: xx, yy = sort_xy(self._ee.keys(), self._ee.values()) else: raise ValueError('if no values for encircled energy have been computed, axlim must be provided') elif axlim is 0: raise ValueError('computing from 0 to 0 is stupid') else: xx = m.linspace(1e-5, axlim, npts) yy = self.encircled_energy(xx) fig, ax = share_fig_ax(fig, ax) ax.plot(xx, yy, lw=3) ax.set(xlabel=r'Image Plane Distance [$\mu m$]', ylabel=r'Encircled Energy [Rel 1.0]', xlim=(0, axlim)) return fig, ax
# plotting ----------------------------------------------------------------- # helpers ------------------------------------------------------------------ def _renorm(self, to='peak'): """Renormalize the PSF to unit peak intensity. Parameters ---------- to : `string`, {'peak', 'total'} renormalization target; produces a PSF of unit peak or total intensity Returns ------- `PSF` a renormalized PSF instance """ if to.lower() == 'peak': self.data /= self.data.max() elif to.lower() == 'total': ttl = self.data.sum() self.data /= ttl return self # helpers ------------------------------------------------------------------
[docs] @staticmethod def from_pupil(pupil, efl, Q=2): """Use scalar diffraction propogation to generate a PSF from a pupil. Parameters ---------- pupil : `Pupil` Pupil, with OPD data and wavefunction efl : `int` or `float` effective focal length of the optical system Q : `int` or `float` ratio of pupil sample count to PSF sample count; Q > 2 satisfies nyquist Returns ------- `PSF` A new PSF instance """ # propagate PSF data fcn, ss, wvl = pupil.fcn, pupil.sample_spacing, pupil.wavelength data = prop_pupil_plane_to_psf_plane(fcn, Q) ux, uy = prop_pupil_plane_to_psf_plane_units(fcn, ss, efl, wvl, Q) psf = PSF(data, ux, uy) # determine the F/#, assumes: # - pupil fills x or y width of array # - pupil is not elliptical at an odd angle s = fcn.shape if s[1] > s[0]: u = pupil.unit_x else: u = pupil.unit_y epd = u[-1] - u[0] psf.fno = efl / epd psf.wavelength = wvl return psf
class AiryDisk(PSF): """An airy disk, the PSF of a circular aperture.""" def __init__(self, fno, wavelength, extent, samples): """Create a new AiryDisk. Parameters ---------- fno : `float` F/# associated with the PSF wavelength : `float` wavelength of light, in microns extent : `float` cartesian window half-width, e.g. 10 will make an RoI 20x20 microns wide samples : `int` number of samples across full width """ x = m.linspace(-extent, extent, samples) y = m.linspace(-extent, extent, samples) xx, yy = m.meshgrid(x, y) rho, phi = cart_to_polar(xx, yy) data = _airydisk(rho, fno, wavelength) self.fno = fno self.wavelength = wavelength super().__init__(data, x, y) self.has_analytic_ft = True def analytic_ft(self, unit_x, unit_y): """Analytic fourier transform of an airy disk. Parameters ---------- unit_x : `numpy.ndarray` sample points in x axis unit_y : `numpy.ndarray` sample points in y axis Returns ------- `numpy.ndarray` 2D numpy array containing the analytic fourier transform """ from .otf import diffraction_limited_mtf r, p = cart_to_polar(*m.meshgrid(unit_x, unit_y)) return diffraction_limited_mtf(self.fno, self.wavelength, r*1e3) # um to mm def _airydisk(unit_r, fno, wavelength): """Compute the airy disk function over a given spatial distance. Parameters ---------- unit_r : `numpy.ndarray` ndarray with units of um fno : `float` F/# of the system wavelength : `float` wavelength of light, um Returns ------- `numpy.ndarray` ndarray containing the airy pattern """ u_eff = unit_r * m.pi / wavelength / fno return abs(2 * m.jinc(u_eff)) ** 2 def _encircled_energy_core(mtf_data, radius, nu_p, dx, dy): """Core computation of encircled energy, based on Baliga 1988. Parameters ---------- mtf_data : `numpy.ndarray` unaliased MTF data radius : `float` radius of "detector" nu_p : `numpy.ndarray` radial spatial frequencies dx : `float` x frequency delta dy : `float` y frequency delta Returns ------- `float` encircled energy for given radius """ integration_fourier = m.j1(2 * m.pi * radius * nu_p) / nu_p # division by nu_p will cause a NaN at the origin, 0.5 is the # analytical value of jinc there integration_fourier[m.isnan(integration_fourier)] = 0.5 dat = mtf_data * integration_fourier return radius * dat.sum() * dx * dy def _analytical_encircled_energy(fno, wavelength, points): """Compute the analytical encircled energy for a diffraction limited circular aperture. Parameters ---------- fno : `float` F/# wavelength : `float` wavelength of light points : `numpy.ndarray` radii of "detector" Returns ------- `numpy.ndarray` encircled energy values """ p = points * m.pi / fno / wavelength return 1 - m.j0(p)**2 - m.j1(p)**2 def _inverse_analytic_encircled_energy(fno, wavelength, energy=FIRST_AIRY_ENCIRCLED): def optfcn(x): return abs(_analytical_encircled_energy(fno, wavelength, x) - energy) return optimize.golden(optfcn)