def from_file(path, scale): '''Read a monochrome 8 bit per pixel file into a new Image instance. Parameters ---------- path : `string` path to a file scale : `float` pixel scale, in microns Returns ------- `Image` a new image object Notes ----- TODO: proper handling of images with more than 8bpp. ''' from imageio import imread imgarr = imread(path, flatten=True, pilmode='F') s = imgarr.shape extx, exty = s[0] * scale // 2, s[1] * scale // 2 ux, uy = m.arange(-extx, exty, scale), m.arange(-exty, exty, scale) return Convolvable(data=m.flip(imgarr, axis=0) / 255, unit_x=ux, unit_y=uy, has_analytic_ft=False)
def __init__(self, phase, intensity=None, x=None, y=None, scale='px', phase_unit='nm', meta=None): if x is None: # assume x, y given together x = m.arange(phase.shape[1]) y = m.arange(phase.shape[0]) scale = 'px' self.lateral_res = 1 if meta: wvl = meta.get('wavelength', None) if wvl is None: wvl = meta.get('Wavelength') if wvl is not None: wvl *= 1e6 # m to um else: wvl = 1 super().__init__(unit_x=x, unit_y=y, phase=phase, wavelength=wvl, phase_unit=phase_unit, spatial_unit=scale) self.xaxis_label = 'X' self.yaxis_label = 'Y' self.zaxis_label = 'Height' self.intensity = intensity self.meta = meta if scale != 'px': self.change_spatial_unit(to=scale, inplace=True)
def from_file(path, scale): """Read a monochrome 8 bit per pixel file into a new Image instance. Parameters ---------- path : `string` path to a file scale : `float` pixel scale, in microns Returns ------- `Convolvable` a new image object """ from imageio import imread imgarr = imread(path) s = imgarr.shape extx, exty = (s[1] * scale) / 2, (s[0] * scale) / 2 ux, uy = m.arange(-extx, extx, scale), m.arange(-exty, exty, scale) return Convolvable(data=m.flip(imgarr, axis=0).astype(config.precision), unit_x=ux, unit_y=uy, has_analytic_ft=False)
def capture(self, convolvable): """Sample a convolvable, mimics capturing a photo of an oversampled representation of an image. Parameters ---------- convolvable : `prysm.Convolvable` a convolvable object Returns ------- `prysm.convolvable` a new convolvable object, as it would be sampled by the detector Raises ------ ValueError if the convolvable would have to become supersampled by the detector; this would lead to an inaccurate result and is not supported """ # we assume the pixels are bigger than the samples in the convolvable samples_per_pixel = self.pixel_size / convolvable.sample_spacing if samples_per_pixel < 1: raise ValueError('Pixels smaller than samples, bindown not possible.') else: samples_per_pixel = int(m.ceil(samples_per_pixel)) data = bindown(convolvable.data, samples_per_pixel) s = data.shape extx, exty = s[0] * self.pixel_size // 2, s[1] * self.pixel_size // 2 ux, uy = m.arange(-extx, exty, self.pixel_size), m.arange(-exty, exty, self.pixel_size) self.captures.append(Convolvable(data=data, unit_x=ux, unit_y=uy, has_analytic_ft=False)) return self.captures[-1]
def __init__(self, phase, intensity=None, x=None, y=None, scale='px', phase_unit='nm', meta=None): if x is None: # assume x, y given together x = m.arange(phase.shape[1]) y = m.arange(phase.shape[0]) scale = 'px' self.lateral_res = 1 super().__init__(unit_x=x, unit_y=y, phase=phase, wavelength=meta.get('wavelength'), phase_unit=phase_unit, spatial_unit='m' if scale == 'px' else scale) self.xaxis_label = 'X' self.yaxis_label = 'Y' self.zaxis_label = 'Height' self.intensity = intensity self.meta = meta if scale != 'px': self.change_spatial_unit(to=scale, inplace=True) self._psd = None self._psdargs = {}
def __init__(self, spokes, sinusoidal=True, background='black', sample_spacing=2, samples=256): """Produces a Siemen's Star. Parameters ---------- spokes : `int` number of spokes in the star. sinusoidal : `bool` if True, generates a sinusoidal Siemen' star, else, generates a bar/block siemen's star background : 'string', {'black', 'white'} background color sample_spacing : `float` spacing of samples, in microns samples : `int` number of samples per dimension in the synthetic image Raises ------ ValueError background other than black or white """ relative_width = 0.9 self.spokes = spokes # generate a coordinate grid x = m.linspace(-1, 1, samples) y = m.linspace(-1, 1, samples) xx, yy = m.meshgrid(x, y) rv, pv = cart_to_polar(xx, yy) ext = sample_spacing * samples / 2 ux, uy = m.arange(-ext, ext, sample_spacing), m.arange(-ext, ext, sample_spacing) # generate the siemen's star as a (rho,phi) polynomial arr = m.cos(spokes / 2 * pv) if not sinusoidal: # make binary arr[arr < 0] = -1 arr[arr > 0] = 1 # scale to (0,1) and clip into a disk arr = (arr + 1) / 2 if background.lower() in ('b', 'black'): arr[rv > relative_width] = 0 elif background.lower() in ('w', 'white'): arr[rv > relative_width] = 1 else: raise ValueError('invalid background color') super().__init__(data=arr, unit_x=ux, unit_y=uy, has_analytic_ft=False)
def gaussian(sigma=0.5, samples=128): """Generate a gaussian mask with a given sigma. Parameters ---------- sigma : `float` width parameter of the gaussian, expressed in samples of the output array samples : `int` number of samples in square array Returns ------- `numpy.ndarray` mask with gaussian shape """ s = sigma x = m.arange(0, samples, 1, dtype=config.precision) y = x[:, m.newaxis] # // is floor division in python x0 = y0 = samples // 2 return m.exp(-4 * m.log(2) * ((x - x0)**2 + (y - y0)**2) / (s * samples)**2)
def read_mtfmapper_sfr_single(file, pixel_pitch=None): """Read an MTF Mapper SFR (MTF) file generated by the -f flag with --single-roi. Notes ----- This reads a "raw_sfr_values.txt" file, not an "edge_sfr_values.txt" file. Parameters ---------- file : `str` or path_like or file_like contents of a file, path_like to the file, or file object pixel_pitch : `float` center-to-center pixel spacing, in microns Returns ------- `numpy.ndarray` spatial_frequencies `numpy.ndarray` mtf """ data = read_file_stream_or_path(file) floats = [float(d) for d in data.splitlines()[0].split(' ')[:-1]] edge_angle, *mtf = floats mtf = m.asarray(mtf) freqs = m.arange(len(mtf)) / 64 if pixel_pitch is not None: # convert cy/px to cy/mm freqs /= (pixel_pitch / 1e3) return freqs, mtf
def prop_pupil_plane_to_psf_plane_units(wavefunction, input_sample_spacing, prop_dist, wavelength, Q): """Compute the ordinate axes for a pupil plane to PSF plane propagation. Parameters ---------- wavefunction : `numpy.ndarray` the pupil wavefunction input_sample_spacing : `float` spacing between samples in the pupil plane prop_dist : `float` propagation distance along the z distance wavelength : `float` wavelength of light Q : `float` oversampling / padding factor Returns ------- unit_x : `numpy.ndarray` x axis unit, 1D ndarray unit_y : `numpy.ndarray` y axis unit, 1D ndarray """ s = wavefunction.shape samples_x, samples_y = s[1] * Q, s[0] * Q sample_spacing_x = pupil_sample_to_psf_sample( pupil_sample=input_sample_spacing, # factor of samples=samples_x, # 1e3 corrects wavelength=wavelength, # for unit efl=prop_dist) / 1e3 # translation sample_spacing_y = pupil_sample_to_psf_sample( pupil_sample=input_sample_spacing, # factor of samples=samples_y, # 1e3 corrects wavelength=wavelength, # for unit efl=prop_dist) / 1e3 # translation unit_x = m.arange(-1 * int(m.ceil(samples_x / 2)), int(m.floor(samples_x / 2))) * sample_spacing_x unit_y = m.arange(-1 * int(m.ceil(samples_y / 2)), int(m.floor(samples_y / 2))) * sample_spacing_y return unit_x, unit_y
def from_zygo_dat(path, multi_intensity_action='first', scale='mm'): """Create a new interferogram from a zygo dat file. Parameters ---------- path : path_like path to a zygo dat file multi_intensity_action : str, optional see `io.read_zygo_dat` scale : `str`, optional, {'um', 'mm'} what xy scale to label the data with, microns or mm Returns ------- `Interferogram` new Interferogram instance """ if str(path).endswith('datx'): # datx file, use datx reader zydat = read_zygo_datx(path) res = zydat['meta']['Lateral Resolution'] else: # dat file, use dat file reader zydat = read_zygo_dat(path, multi_intensity_action=multi_intensity_action) res = zydat['meta']['lateral_resolution'] # meters phase = zydat['phase'] if res == 0.0: res = 1 scale = 'px' if scale != 'px': _scale = 'm' else: _scale = 'px' i = Interferogram(phase=phase, intensity=zydat['intensity'], x=m.arange(phase.shape[1]) * res, y=m.arange(phase.shape[0]) * res, scale=_scale, meta=zydat['meta']) return i.change_spatial_unit(to=scale.lower(), inplace=True)
def read_trioptics_mtfvfvf(file, filename=None): """Read MTF vs Field vs Focus data from a Trioptics .txt dump. Parameters ---------- file : `str` or path_like or file_like file to read from, if string of file body, must provide filename filename : `str`, optional name of file; used to select tan/sag if file is given as contents Returns ------- `MTFvFvF` MTF vs Field vs Focus object """ if filename is None: with open(file, 'r') as fid: lines = fid.readlines() else: lines = file.splitlines() file = filename if str(file)[-7:-4] == 'Tan': azimuth = 'Tan' else: azimuth = 'Sag' imghts, objangs, focusposes, mtfs = [], [], [], [] for meta, data in zip(lines[0::2], lines[1::2]): # iterate 2 lines at a time metavalues = meta.split() imght, objang, focuspos, freqpitch = metavalues[1::2] mtf_raw = data.split()[1:] # first element is "MTF" mtf = m.asarray(mtf_raw, dtype=config.precision) imghts.append(imght) objangs.append(objang) focusposes.append(focuspos) mtfs.append(mtf) focuses = m.unique(m.asarray(focusposes, dtype=config.precision)) focuses = (focuses - m.mean(focuses)) * 1e3 imghts = m.unique(m.asarray(imghts, dtype=config.precision)) freqs = m.arange(len(mtfs[0]), dtype=config.precision) * float(freqpitch) data = m.swapaxes( m.asarray(mtfs).reshape(len(focuses), len(imghts), len(freqs)), 0, 1) return { 'data': data, 'focus': focuses, 'field': imghts, 'freq': freqs, 'azimuth': azimuth }
def change_spatial_unit(self, to, inplace=True): """Change the units used to describe the spatial dimensions. Parameters ---------- to : `str` new unit, a member of `OpticalPhase`.units.keys() inplace : `bool`, optional whether to change self.unit_x and self.unit_y. If False, returns updated phase, if True, returns self Returns ------- `new_ux` : `np.ndarray` new ordinate x axis `new_uy` : `np.ndarray` new ordinate y axis OR `self` : `OpticalPhase` self """ if to.lower() != 'px': fctr = self.unit_changes['_'.join( [self.spatial_unit, self.units[to]])](self.wavelength) new_ux = self.unit_x / fctr new_uy = self.unit_y / fctr else: sy, sx = self.shape new_ux = m.arange(sx, dtype=config.precision) new_uy = m.arange(sy, dtype=config.precision) if inplace: self.unit_x = new_ux self.unit_y = new_uy self.spatial_unit = to return self else: return new_ux, new_uy
def psd(height, sample_spacing): """Compute the power spectral density of a signal. Parameters ---------- height : `numpy.ndarray` height or phase data sample_spacing : `float` spacing of samples in the input data Returns ------- unit_x : `numpy.ndarray` ordinate x frequency axis unit_y : `numpy.ndarray` ordinate y frequency axis psd : `numpy.ndarray` power spectral density """ s = height.shape window = window_2d_welch( m.arange(s[1]) * sample_spacing, m.arange(s[0]) * sample_spacing) window = m.ones(height.shape) psd = prop_pupil_plane_to_psf_plane(height * window, norm='ortho') ux = forward_ft_unit(sample_spacing, int(round(height.shape[1], 0))) uy = forward_ft_unit(sample_spacing, int(round(height.shape[0], 0))) psd /= height.size # adjustment for 2D welch window bias, there is room for improvement to this # approximate value from: # Power Spectral Density Specification and Analysis of Large Optical Surfaces # E. Sidick, JPL psd /= 0.925 return ux, uy, psd
def _uniformly_spaced_fields(self, num_pts): ''' Changes the `fields` property to n evenly spaced points from 0~1. Args: num_pts (`int`): number of points. Returns: self. ''' _ = m.arange(0, num_pts, dtype=config.precision) flds = _ / _.max() self.fields = flds return self
def synthesize_surface_from_psd(psd, nu_x, nu_y): """Synthesize a surface height map from PSD data. Parameters ---------- psd : `numpy.ndarray` PSD data, units nm²/(cy/mm)² nu_x : `numpy.ndarray` x spatial frequency, cy/mm nu_y : `numpy.ndarray` y spatial frequency, cy_mm """ # generate a random phase to be matched to the PSD randnums = m.rand(*psd.shape) randfft = m.fft2(randnums) phase = m.angle(randfft) # calculate the output window # the 0th element of nu_y has the greatest frequency in magnitude because of # the convention to put the nyquist sample at -fs instead of +fs for even-size arrays fs = -2 * nu_y[0] dx = dy = 1 / fs ny, nx = psd.shape x, y = m.arange(nx) * dx, m.arange(ny) * dy # calculate the area of the output window, "S2" in GH_FFT notation A = x[-1] * y[-1] # use ifft to compute the PSD signal = m.exp(1j * phase) * m.sqrt(A * psd) coef = 1 / dx / dy out = m.ifftshift(m.ifft2(m.fftshift(signal))) * coef out = out.real return x, y, out
def ecdf(x): """Compute the empirical cumulative distribution function of a dataset. Parameters ---------- x : `iterable` Data Returns ------- xs : `numpy.ndarray` sorted data ys : `numpy.ndarray` cumulative distribution function of the data """ xs = m.sort(x) ys = m.arange(1, len(xs) + 1) / float(len(xs)) return xs, ys
def top_n(self, n=5): """Identify the top n terms in the wavefront. Parameters ---------- n : `int`, optional identify the top n terms. Returns ------- `list` list of tuples (magnitude, index, term) """ coefs = m.asarray(self.coefs) coefs_work = abs(coefs) oidxs = m.arange(len(coefs), dtype=int) + self.base # "original indexes" idxs = m.argpartition(coefs_work, -n)[-n:] # argpartition does some magic to identify the top n (unsorted) idxs = idxs[m.argsort(coefs_work[idxs])[::-1]] # use argsort to sort them in ascending order and reverse big_terms = coefs[idxs] # finally, take the values from the big_idxs = oidxs[idxs] names = m.asarray(self.names, dtype=str)[big_idxs - self.base] return list(zip(big_terms, big_idxs, names))
def generate_mask(vertices, num_samples=128): """Create a filled convex polygon mask based on the given vertices. Parameters ---------- vertices : `iterable` ensemble of vertice (x,y) coordinates, in array units num_samples : `int` number of points in the output array along each dimension Returns ------- `numpy.ndarray` polygon mask """ vertices = m.asarray(vertices) unit = m.arange(num_samples) xxyy = m.stack(m.meshgrid(unit, unit), axis=2) # use delaunay to fill from the vertices and produce a mask triangles = Delaunay(vertices, qhull_options='Qj Qf') mask = ~(triangles.find_simplex(xxyy) < 0) return mask
def make_window(signal, sample_spacing, which='welch'): """Generate a window function to be used in PSD analysis. Parameters ---------- signal : `numpy.ndarray` signal or phase data sample_spacing : `float` spacing of samples in the input data which : `str,` {'welch', 'hann', 'auto'}, optional which window to produce. If auto, attempts to guess the appropriate window based on the input signal Notes ----- For 2D welch, see: Power Spectral Density Specification and Analysis of Large Optical Surfaces E. Sidick, JPL Returns ------- `numpy.ndarray` window array """ s = signal.shape if which is None: # attempt to guess best window ysamples = int(round(s[0] * 0.02, 0)) xsamples = int(round(s[1] * 0.02, 0)) corner1 = signal[:ysamples, :xsamples] == 0 corner2 = signal[-ysamples:, :xsamples] == 0 corner3 = signal[:ysamples, -xsamples:] == 0 corner4 = signal[-ysamples:, -xsamples:] == 0 if corner1.all() and corner2.all() and corner3.all() and corner4.all(): # four corners all "black" -- circular data, Welch window is best # looks wrong but 2D welch takes x, y while indices are y, x y = m.arange(s[1]) * sample_spacing x = m.arange(s[0]) * sample_spacing return window_2d_welch(y, x) else: # if not circular, square data; use Hanning window y = m.hanning(s[0]) x = m.hanning(s[1]) return m.outer(y, x) else: if type(which) is str: # known window type wl = which.lower() if wl == 'welch': y = m.arange(s[1]) * sample_spacing x = m.arange(s[0]) * sample_spacing return window_2d_welch(y, x) elif wl in ('hann', 'hanning'): y = m.hanning(s[0]) x = m.hanning(s[1]) return m.outer(y, x) else: raise ValueError('unknown window type') else: return which # window provided as ndarray
def plot2d(self, axlim=25, power=1, interp_method='lanczos', pix_grid=None, fig=None, ax=None, show_axlabels=True, show_colorbar=True, circle_ee=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 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 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 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.]' lims = (0, 1) left, right = self.unit_x[0], self.unit_x[-1] bottom, top = self.unit_y[0], self.unit_y[-1] fig, ax = share_fig_ax(fig, ax) im = ax.imshow(self.data, extent=[left, right, bottom, top], origin='lower', cmap='Greys_r', norm=colors.PowerNorm(1 / power), interpolation=interp_method, clim=lims) if show_colorbar: cb = fig.colorbar(im, label=label_str, ax=ax, fraction=0.046) 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='--') c_true = patches.Circle((0, 0), radius, fill=False, color='r') ax.add_artist(c_diff) ax.add_artist(c_true) return fig, ax
def plot2d(self, axlim=25, interp_method='lanczos', pix_grid=None, fig=None, ax=None): '''Create a 2D color plot of the PSF. Parameters ---------- axlim : `float` limits of axis, symmetric. xlim=(-axlim,axlim), ylim=(-axlim, axlim) interp_method : `str` 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 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 ''' dat = m.empty((self.samples_x, self.samples_y, 3)) dat[:, :, 0] = self.R dat[:, :, 1] = self.G dat[:, :, 2] = self.B left, right = self.unit_y[0], self.unit_y[-1] bottom, top = self.unit_x[0], self.unit_x[-1] fig, ax = share_fig_ax(fig, ax) ax.imshow(dat, extent=[left, right, bottom, top], interpolation=interp_method, origin='lower') ax.set(xlabel=r'Image Plane X [$\mu m$]', ylabel=r'Image Plane Y [$\mu m$]', 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') ax.xaxis.grid(True, which='minor') return fig, ax
def plot2d_rgbgrid(self, axlim=25, interp_method='lanczos', pix_grid=None, fig=None, ax=None): """Create a 2D color plot of the PSF and R,G,B components. Parameters ---------- axlim : `float` limits of axis, symmetric. xlim=(-axlim,axlim), ylim=(-axlim, axlim) interp_method : `str` 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 fig : `matplotlib.figure.Figure`, optional Figure containing the plot ax : `matplotlib.axes.Axis`, optional: Axis containing the plot fig : `matplotlib.figure.Figure`, optional Figure containing the plot ax : `matplotlib.axes.Axis`, optional: Axis containing the plot Notes ----- Need to refine internal workings at some point. """ # make the arrays for the RGB images dat = m.empty((self.samples_y, self.samples_x, 3)) datr = m.zeros((self.samples_y, self.samples_x, 3)) datg = m.zeros((self.samples_y, self.samples_x, 3)) datb = m.zeros((self.samples_y, self.samples_x, 3)) dat[:, :, 0] = self.R dat[:, :, 1] = self.G dat[:, :, 2] = self.B datr[:, :, 0] = self.R datg[:, :, 1] = self.G datb[:, :, 2] = self.B left, right = self.unit[0], self.unit[-1] ax_width = 2 * axlim # generate a figure and axes to plot in fig, ax = share_fig_ax(fig, ax) axr, axg, axb = make_rgb_axes(ax) ax.imshow(dat, extent=[left, right, left, right], interpolation=interp_method, origin='lower') axr.imshow(datr, extent=[left, right, left, right], interpolation=interp_method, origin='lower') axg.imshow(datg, extent=[left, right, left, right], interpolation=interp_method, origin='lower') axb.imshow(datb, extent=[left, right, left, right], interpolation=interp_method, origin='lower') for axs in (ax, axr, axg, axb): ax.set(xlim=(-axlim, axlim), ylim=(-axlim, axlim)) if pix_grid is not None: # if pixel grid is desired, add it mult = m.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') ax.xaxis.grid(True, which='minor') ax.set(xlabel=r'Image Plane X [$\mu m$]', ylabel=r'Image Plane Y [$\mu m$]') axr.text(-axlim + 0.1 * ax_width, axlim - 0.2 * ax_width, 'R', color='white') axg.text(-axlim + 0.1 * ax_width, axlim - 0.2 * ax_width, 'G', color='white') axb.text(-axlim + 0.1 * ax_width, axlim - 0.2 * ax_width, 'B', color='white') return fig, ax
def plot2d(self, freq, show_contours=True, cmap='inferno', clim=(0, 1), show_cb=True, fig=None, ax=None): """Plot the MTF FFD. Parameters ---------- freq : `float` frequency to plot at show_contours : `bool` whether to plot contours cmap : `str` colormap to pass to `imshow` clim : `iterable` length 2 iterable with lower, upper bounds of colors show_cb : `bool` whether to show the colorbar or not fig : `matplotlib.figure.Figure`, optional figure containing the plot ax : `matplotlib.axes.Axis` axis containing the plot Returns ------- fig : `matplotlib.figure.Figure`, optional figure containing the plot axis : `matplotlib.axes.Axis` axis containing the plot """ idx = m.searchsorted(self.freq, freq) extx = (self.field_x[0], self.field_x[-1]) exty = (self.field_y[0], self.field_y[-1]) ext = [*extx, *exty] fig, ax = share_fig_ax(fig, ax) im = ax.imshow(self.data[:, :, idx], extent=ext, origin='lower', interpolation='gaussian', cmap=cmap, clim=clim) if show_contours is True: if clim[0] < 0: contours = list(m.arange(clim[0], clim[1] + 0.1, 0.1)) else: contours = [ 0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0 ] cs = ax.contour(self.data[:, :, idx], contours, colors='0.15', linewidths=0.75, extent=ext) ax.clabel(cs, fmt='%1.1f', rightside_up=True) ax.set(xlabel='Image Plane X [mm]', ylabel='Image Plane Y [mm]') if show_cb: fig.colorbar(im, label=f'MTF @ {freq} cy/mm', ax=ax, fraction=0.046) return fig, ax
def plot2d(self, axlim=25, power=1, clim=(None, None), interp_method='lanczos', pix_grid=None, cmap=config.image_colormap, 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 cmap : `str`, optional colormap, passed directly to matplotlib 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 """ from matplotlib import colors, patches 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] 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='Image Plane x [μm]', ylabel='Image Plane y [μ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
def trace_focus(self, algorithm='avg'): """Find the focus position in each field. This is, in effect, the "field curvature" for this azimuth. Parameters ---------- algorithm : `str` algorithm to use to trace focus, currently only supports '0.5', see notes for a description of this technique Returns ------- field : `numpy.ndarray` array of field values, mm focus : `numpy.ndarray` array of focus values, microns Notes ----- Algorithm '0.5' uses the frequency that has its peak closest to 0.5 on-axis to estimate the focus coresponding to the minimum RMS WFE condition. This is based on the following assumptions: - Any combination of third, fifth, and seventh order spherical aberration will produce a focus shift that depends on frequency, and this dependence can be well fit by an equation of the form y(x) = ax^2 + bx + c. If this is true, then the frequency which peaks at 0.5 will be near the vertex of the quadratic, which converges to the min RMS WFE condition. - Coma, while it enhances depth of field, does not shift the focus peak. - Astigmatism and field curvature are the dominant cause of any shift in best focus with field. - Chromatic aberrations do not influence the thru-focus MTF peak in a way that varies with field. Raises ------ ValueError if an unsupported algorithm is entered """ if algorithm == '0.5': # locate the frequency index on axis idx_axis = m.searchsorted(self.field, 0) idx_freq = abs(self.data[:, idx_axis, :].max(axis=0) - 0.5).argmin(axis=0) focus_idx = self.data[:, m.arange(self.data.shape[1]), idx_freq].argmax(axis=0) return self.field, self.focus[focus_idx], elif algorithm.lower() in ('avg', 'average'): if self.freq[0] == 0: # if the zero frequency is included, exclude it from our calculations avg_idxs = self.data.argmax(axis=0)[:, 1:].mean(axis=1) else: avg_idxs = self.data.argmax(axis=0).mean(axis=1) # account for fractional indexes focus_out = avg_idxs.copy() for i, idx in enumerate(avg_idxs): li, ri = int(m.floor(idx)), int(m.ceil(idx)) lf, rf = self.focus[li], self.focus[ri] diff = rf - lf part = idx % 1 focus_out[i] = lf + diff * part return self.field, focus_out else: raise ValueError('0.5 is only algorithm supported')