def __init__(self, width, sample_spacing=None, samples=0): """Create a Pinhole instance. Parameters ---------- width : `float` the width of the pinhole sample_spacing : `float` spacing of samples in the synthetic image samples : `int` number of samples per dimension in the synthetic image Notes ----- Default of 0 samples allows quick creation for convolutions without generating the image; use samples > 0 for an actual image. """ self.width = width # produce coordinate arrays if samples > 0: ext = samples / 2 * sample_spacing x, y = m.linspace(-ext, ext, samples), m.linspace(-ext, ext, samples) xv, yv = m.meshgrid(x, y) w = width / 2 # paint a circle on a black background arr = m.zeros((samples, samples)) arr[m.sqrt(xv**2 + yv**2) < w] = 1 else: arr, x, y = None, None, None super().__init__(data=arr, unit_x=x, unit_y=y, has_analytic_ft=True)
def rotated_ellipse(width_major, width_minor, major_axis_angle=0, samples=128): """Generate a binary mask for an ellipse, centered at the origin. The major axis will notionally extend to the limits of the array, but this will not be the case for rotated cases. Parameters ---------- width_major : `float` width of the ellipse in its major axis width_minor : `float` width of the ellipse in its minor axis major_axis_angle : `float` angle of the major axis w.r.t. the x axis, degrees samples : `int` number of samples Returns ------- `numpy.ndarray` An ndarray of shape (samples,samples) of value 0 outside the ellipse, and value 1 inside the ellipse Notes ----- The formula applied is: ((x-h)cos(A)+(y-k)sin(A))^2 ((x-h)sin(A)+(y-k)cos(A))^2 ______________________________ + ______________________________ 1 a^2 b^2 where x and y are the x and y dimensions, A is the rotation angle of the major axis, h and k are the centers of the the ellipse, and a and b are the major and minor axis widths. In this implementation, h=k=0 and the formula simplifies to: (x*cos(A)+y*sin(A))^2 (x*sin(A)+y*cos(A))^2 ______________________________ + ______________________________ 1 a^2 b^2 see SO: https://math.stackexchange.com/questions/426150/what-is-the-general-equation-of-the-ellipse-that-is-not-in-the-origin-and-rotate Raises ------ ValueError Description """ if width_minor > width_major: raise ValueError( 'By definition, major axis must be larger than minor.') arr = m.ones((samples, samples)) lim = width_major x, y = m.linspace(-lim, lim, samples), m.linspace(-lim, lim, samples) xv, yv = m.meshgrid(x, y) A = m.radians(-major_axis_angle) a, b = width_major, width_minor major_axis_term = ((xv * m.cos(A) + yv * m.sin(A))**2) / a**2 minor_axis_term = ((xv * m.sin(A) - yv * m.cos(A))**2) / b**2 arr[major_axis_term + minor_axis_term > 1] = 0 return arr
def __init__(self, fno, wavelength, extent=None, samples=None): """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 """ if samples is not None: 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) else: x, y, data = None, None, None self.fno = fno self.wavelength = wavelength super().__init__(data, x, y) self.has_analytic_ft = True
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 __init__(self, width, orientation='Vertical', sample_spacing=None, samples=0): """Create a new Slit instance. Parameters ---------- width : `float` the width of the slit in microns orientation : `string`, {'Horizontal', 'Vertical', 'Crossed', 'Both'} the orientation of the slit; Crossed and Both produce the same results sample_spacing : `float` spacing of samples in the synthetic image samples : `int` number of samples per dimension in the synthetic image Notes ----- Default of 0 samples allows quick creation for convolutions without generating the image; use samples > 0 for an actual image. """ w = width / 2 if samples > 0: ext = samples / 2 * sample_spacing x, y = m.linspace(-ext, ext, samples), m.linspace(-ext, ext, samples) arr = m.zeros((samples, samples)) else: arr, x, y = None, None, None # paint in the slit if orientation.lower() in ('v', 'vert', 'vertical'): if samples > 0: arr[:, abs(x) < w] = 1 self.orientation = 'Vertical' self.width_x = width self.width_y = 0 elif orientation.lower() in ('h', 'horiz', 'horizontal'): if samples > 0: arr[abs(y) < w, :] = 1 self.width_x = 0 self.width_y = width self.orientation = 'Horizontal' elif orientation.lower() in ('b', 'both', 'c', 'crossed'): if samples > 0: arr[abs(y) < w, :] = 1 arr[:, abs(x) < w] = 1 self.orientation = 'Crossed' self.width_x, self.width_y = width, width super().__init__(arr, x, y, has_analytic_ft=True)
def fit_sphere(z): x, y = m.linspace(-1, 1, z.shape[1]), m.linspace(-1, 1, z.shape[0]) xx, yy = m.meshgrid(x, y) pts = m.isfinite(z) xx_, yy_ = xx[pts].flatten(), yy[pts].flatten() rho, phi = cart_to_polar(xx_, yy_) focus = defocus(rho, phi) coefs = m.lstsq(m.stack([focus, m.ones(focus.shape)]).T, z[pts].flatten(), rcond=None)[0] rho, phi = cart_to_polar(xx, yy) sphere = defocus(rho, phi) * coefs[0] return sphere
def _compute_output_grid(convolvable1, convolvable2): """Calculate the output grid to be used when two convolvables are on different grids.""" # determine output spacing errorstr = 'when convolving two analytic objects, one must have a grid.' if m.isnan(convolvable1.sample_spacing): if m.isnan(convolvable2.sample_spacing): raise ValueError(errorstr) else: output_spacing = convolvable2.sample_spacing elif m.isnan(convolvable2.sample_spacing): if m.isnan(convolvable1.sample_spacing): raise ValueError(errorstr) else: output_spacing = convolvable1.sample_spacing else: output_spacing = min(convolvable1.sample_spacing, convolvable2.sample_spacing) # determine region of output if convolvable1.unit_x is None: c1ux, c1uy = (0, 0), (0, 0) else: c1ux, c1uy = convolvable1.unit_x, convolvable1.unit_y if convolvable2.unit_x is None: # this isn't totally DRY c2ux, c2uy = (0, 0), (0, 0) else: c2ux, c2uy = convolvable2.unit_x, convolvable2.unit_y output_x_left = min(c1ux[0], c2ux[0]) output_x_right = max(c1ux[-1], c2ux[-1]) output_y_left = min(c1uy[0], c2uy[0]) output_y_right = max(c1uy[-1], c2uy[-1]) # if region is not an integer multiple of sample spacings, enlarge to make this true x_rem = (output_x_right - output_x_left) % output_spacing y_rem = (output_y_right - output_y_left) % output_spacing if x_rem > 1e-3: adj = x_rem / 2 output_x_left -= adj output_x_right += adj if y_rem > 1e-3: adj = y_rem / 2 output_y_left -= adj output_y_right += adj # finally, compute the output window samples_x = int((output_x_right - output_x_left) // output_spacing) samples_y = int((output_y_right - output_y_left) // output_spacing) unit_out_x = m.linspace(output_x_left, output_x_right, samples_x) unit_out_y = m.linspace(output_y_left, output_y_right, samples_y) return unit_out_x, unit_out_y
def __init__(self, width_x, width_y=None, sample_spacing=0.1, samples_x=384, samples_y=None): """Create a new OLPF object. Parameters ---------- width_x : `float` blur width in the x direction, microns width_y : `float` blur width in the y direction, microns sample_spacing : `float`, optional center to center spacing of samples samples_x : `int`, optional number of samples along x axis samples_y : `int`, optional number of samples along y axis; duplicates x if None """ # compute relevant spacings if width_y is None: width_y = width_x if samples_y is None: samples_y = samples_x self.width_x = width_x self.width_y = width_y if samples_x is None: # do no math data, ux, uy = None, m.zeros(2), m.zeros(2) else: space_x = width_x / 2 space_y = width_y / 2 shift_x = int(space_x // sample_spacing) shift_y = int(space_y // sample_spacing) center_x = samples_x // 2 center_y = samples_y // 2 data = m.zeros((samples_x, samples_y)) data[center_y - shift_y, center_x - shift_x] = 1 data[center_y - shift_y, center_x + shift_x] = 1 data[center_y + shift_y, center_x - shift_x] = 1 data[center_y + shift_y, center_x + shift_x] = 1 ux = m.linspace(-space_x, space_x, samples_x) uy = m.linspace(-space_y, space_y, samples_y) super().__init__(data=data, unit_x=ux, unit_y=uy, has_analytic_ft=True)
def __init__(self, angle=4, contrast=0.9, crossed=False, sample_spacing=2, samples=256): """Create a new TitledSquare instance. Parameters ---------- angle : `float` angle in degrees to tilt w.r.t. the y axis contrast : `float` difference between minimum and maximum values in the image crossed : `bool`, optional whether to make a single edge (crossed=False) or pair of crossed edges (crossed=True) aka a "BMW target" sample_spacing : `float` spacing of samples samples : `int` number of samples """ diff = (1 - contrast) / 2 arr = m.full((samples, samples), 1 - diff) x = m.linspace(-0.5, 0.5, samples) y = m.linspace(-0.5, 0.5, samples) xx, yy = m.meshgrid(x, y) sf = samples * sample_spacing angle = m.radians(angle) xp = xx * m.cos(angle) - yy * m.sin(angle) # yp = xx * m.sin(angle) + yy * m.cos(angle) # do not need this mask = xp > 0 # single edge if crossed: mask = xp > 0 # set of 4 edges upperright = mask & m.rot90(mask) lowerleft = m.rot90(upperright, 2) mask = upperright | lowerleft arr[mask] = diff self.contrast = contrast self.black = diff self.white = 1 - diff super().__init__(data=arr, unit_x=x * sf, unit_y=y * sf, has_analytic_ft=False)
def _make_mtf_vs_field_vs_focus(self, num_fields, focus_range, num_focus, freqs): ''' TODO: docstring ''' self._uniformly_spaced_fields(num_fields) net_mtfs = [None] * num_fields for idx in range(num_fields): focus, net_mtfs[idx] = self._make_mtf_thrufocus( idx, focus_range, num_focus) fields = (self.fields[-1] * self.fov_y) * m.linspace(0, 1, num_fields) t_cube = m.empty((num_focus, num_fields, len(freqs))) s_cube = m.empty((num_focus, num_fields, len(freqs))) for idx, mtfs in enumerate(net_mtfs): for idx2, submtf in enumerate(mtfs): t = submtf.exact_polar(freqs, 0) s = submtf.exact_polar(freqs, 90) t_cube[idx2, idx, :] = t s_cube[idx2, idx, :] = s TCube = MTFvFvF(data=t_cube, focus=focus, field=fields, freq=freqs, azimuth='Tan') SCube = MTFvFvF(data=s_cube, focus=focus, field=fields, freq=freqs, azimuth='Sag') return TCube, SCube
def generate_mtf(pixel_aperture=1, azimuth=0, num_samples=128): """Generate the 1D diffraction-limited MTF for a given pixel width and azimuth. Parameters ---------- pixel_aperture : `float` aperture of the pixel, microns. Pixel is assumed to be square azimuth : `float` azimuth to retrieve the MTF at, in degrees num_samples : `int` number of samples in the output array Returns ------- frequencies : `numpy.ndarray` unit axis, cy/mm mtf : `numpy.ndarray` MTF values (rel. 1.0). Notes ----- Azimuth is not actually implemented yet. """ pitch_unit = pixel_aperture / 1e3 normalized_frequencies = m.linspace(0, 2, num_samples) otf = m.sinc(normalized_frequencies) mtf = abs(otf) return normalized_frequencies / pitch_unit, mtf
def _make_mtf_thrufocus(self, field_index, focus_range, num_pts): ''' Makes a list of MTF objects corresponding to different focus shifts for the lens. Focusrange will be applied symmetrically. Args: field_index: (`int`): index of the desired field in the self.fields iterable. focus_range: (`float`): focus range, in microns. num_pts (`int`): number of points to compute MTF at. Note that for and even number of points, the zero defocus point will not be sampled. Returns: list of `MTF` objects. ''' # todo: parallelize focus_shifts = m.linspace(-focus_range, focus_range, num_pts) defocus_wvs = image_displacement_to_defocus(focus_shifts, self.fno, self.wavelength) mtfs = [] pupil = self._make_pupil(field_index) for defocus in defocus_wvs: defocus_p = Seidel(W020=defocus, epd=self.epd, samples=self.samples, wavelength=self.wavelength) psf = PSF.from_pupil(pupil.merge(defocus_p), self.efl) mtfs.append(MTF.from_psf(psf)) return focus_shifts, mtfs
def __init__(self, width_x, width_y=None, sample_spacing=0, samples_x=None, samples_y=None): """Create a new `PixelAperture` object. Parameters ---------- width_x : `float` width of the aperture in the x dimension, in microns. width_y : `float`, optional siez of the aperture in the y dimension, in microns sample_spacing : `float`, optional spacing of samples, in microns samples_x : `int`, optional number of samples in the x dimension samples_y : `int`, optional number of samples in the y dimension """ if width_y is None: width_y = width_x if samples_y is None: samples_y = samples_x self.width_x = width_x self.width_y = width_y if samples_x is None: # do no math data, ux, uy = None, m.zeros(2), m.zeros(2) else: # build PixelAperture model center_x = samples_x // 2 center_y = samples_y // 2 half_width = width_x / 2 half_height = width_y / 2 steps_x = int(half_width // sample_spacing) steps_y = int(half_height // sample_spacing) data = m.zeros((samples_x, samples_y)) data[center_y - steps_y:center_y + steps_y, center_x - steps_x:center_x + steps_x] = 1 extx, exty = samples_x // 2 * sample_spacing, samples_y // 2 * sample_spacing ux, uy = m.linspace(-extx, extx, samples_x), m.linspace(-exty, exty, samples_y) super().__init__(data=data, unit_x=ux, unit_y=uy, has_analytic_ft=True)
def _compute_output_grid(convolvable1, convolvable2): # determine output spacing if convolvable1.sample_spacing < convolvable2.sample_spacing: output_spacing = convolvable1.sample_spacing else: output_spacing = convolvable2.sample_spacing # determine region of output if convolvable1.unit_x[0] < convolvable2.unit_x[0]: output_x_left = convolvable1.unit_x[0] else: output_x_left = convolvable2.unit_x[0] if convolvable1.unit_x[-1] > convolvable2.unit_x[-1]: output_x_right = convolvable1.unit_x[-1] else: output_x_right = convolvable2.unit_x[-1] if convolvable1.unit_y[0] < convolvable2.unit_y[0]: output_y_left = convolvable1.unit_y[0] else: output_y_left = convolvable2.unit_y[0] if convolvable1.unit_y[-1] > convolvable2.unit_y[-1]: output_y_right = convolvable1.unit_y[-1] else: output_y_right = convolvable2.unit_y[-1] # if region is not an integer multiple of sample spacings, enlarge to make this true x_rem = (output_x_right - output_x_left) % output_spacing y_rem = (output_y_right - output_y_left) % output_spacing if x_rem > 1e-3: adj = x_rem / 2 output_x_left -= adj output_x_right += adj if y_rem > 1e-3: adj = y_rem / 2 output_y_left -= adj output_y_right += adj # finally, compute the output window samples_x = (output_x_right - output_x_left) // output_spacing samples_y = (output_y_right - output_y_left) // output_spacing unit_out_x = m.linspace(output_x_left, output_x_right, samples_x) unit_out_y = m.linspace(output_y_left, output_y_right, samples_y) return unit_out_x, unit_out_y
def __init__(self, angle=4, background='white', sample_spacing=2, samples=256): """Create a new TitledSquare instance. Parameters ---------- angle : `float` angle in degrees to tilt w.r.t. the x axis background : `string` white or black; the square will be the opposite color of the background sample_spacing : `float` spacing of samples samples : `int` number of samples """ radius = 0.3 if background.lower() == 'white': arr = m.ones((samples, samples)) fill_with = 0 else: arr = m.zeros((samples, samples)) fill_with = 1 # TODO: optimize by working with index numbers directly and avoid # creation of X,Y arrays for performance. x = m.linspace(-0.5, 0.5, samples) y = m.linspace(-0.5, 0.5, samples) xx, yy = m.meshgrid(x, y) sf = samples * sample_spacing # TODO: convert inline operation to use of rotation matrix angle = m.radians(angle) xp = xx * m.cos(angle) - yy * m.sin(angle) yp = xx * m.sin(angle) + yy * m.cos(angle) mask = (abs(xp) < radius) * (abs(yp) < radius) arr[mask] = fill_with super().__init__(data=arr, unit_x=x * sf, unit_y=y * sf, has_analytic_ft=False)
def fit(data, num_terms=16, rms_norm=False, round_at=6): ''' Fits a number of Zernike coefficients to provided data by minimizing the root sum square between each coefficient and the given data. The data should be uniformly sampled in an x,y grid. Args: data (`numpy.ndarray`): data to fit to. num_terms (`int`): number of terms to fit, fits terms 0~num_terms. rms_norm (`bool`): if true, normalize coefficients to unit RMS value. round_at (`int`): decimal place to round values at. Returns: numpy.ndarray: an array of coefficients matching the input data. ''' if num_terms > len(zernmap): raise ValueError(f'number of terms must be less than {len(zernmap)}') # precompute the valid indexes in the original data pts = m.isfinite(data) # set up an x/y rho/phi grid to evaluate Zernikes on x, y = m.linspace(-1, 1, data.shape[1]), m.linspace(-1, 1, data.shape[0]) xx, yy = m.meshgrid(x, y) rho = m.sqrt(xx**2 + yy**2)[pts].flatten() phi = m.arctan2(xx, yy)[pts].flatten() # compute each Zernike term zernikes = [] for i in range(num_terms): func = z.zernikes[zernmap[i]] base_zern = func(rho, phi) if rms_norm: base_zern *= func.norm zernikes.append(base_zern) zerns = m.asarray(zernikes).T # use least squares to compute the coefficients coefs = m.lstsq(zerns, data[pts].flatten(), rcond=None)[0] return coefs.round(round_at)
def uniform_cart_to_polar(x, y, data): """Interpolate data uniformly sampled in cartesian coordinates to polar coordinates. Parameters ---------- x : `numpy.ndarray` sorted 1D array of x sample pts y : `numpy.ndarray` sorted 1D array of y sample pts data : `numpy.ndarray` data sampled over the (x,y) coordinates Returns ------- rho : `numpy.ndarray` samples for interpolated values phi : `numpy.ndarray` samples for interpolated values f(rho,phi) : `numpy.ndarray` data uniformly sampled in (rho,phi) """ # create a set of polar coordinates to interpolate onto xmin, xmax = min(x), max(x) ymin, ymax = min(y), max(y) _max = max(abs(m.asarray([xmin, xmax, ymin, ymax]))) rho = m.linspace(0, _max, len(x)) phi = m.linspace(0, 2 * m.pi, len(y)) rv, pv = m.meshgrid(rho, phi) # map points to x, y and make a grid for the original samples xv, yv = polar_to_cart(rv, pv) # interpolate the function onto the new points f = interpolate.RegularGridInterpolator((y, x), data, bounds_error=False, fill_value=0) return rho, phi, f((yv, xv), method='linear')
def plot_mtf_vs_field(self, num_pts, freqs=[10, 20, 30, 40, 50], title='MTF vs Field', minorgrid=True, fig=None, ax=None): ''' Generates a plot of the MTF vs Field for the lens. Args: num_pts (`int`): number of field points to evaluate. freqs (`iterable`): frequencies to evaluate the MTF at. fig (`matplotlib.pyplot.figure`): figure to plot inside. ax (`matplotlib.pyplot.axis`): axis to plot ini. Return: `tuple` containing: `matplotlib.pyplot.figure` figure containing the plot. `matplotlib.pyplot.axis` axis containing the plot. ''' data_s, data_t = self.mtf_vs_field(num_pts, freqs) flds_abs = m.linspace(0, self.fov_y, num_pts) fig, ax = share_fig_ax(fig, ax) for i in range(len(freqs)): ln, = ax.plot(flds_abs, data_s[:, i], lw=3, ls='--') ax.plot(flds_abs, data_t[:, i], lw=3, color=ln.get_color(), label=f'{freqs[i]}lp/mm') ax.plot(0, 0, color='k', ls='--', label='Sag') ax.plot(0, 0, color='k', label='Tan') # todo: respect units of `self` if minorgrid is True: ax.set_yticks([0.1, 0.3, 0.5, 0.7, 0.9], minor=True) ax.grid(True, which='minor') ax.set(xlim=(0, self.fov_y), xlabel='Image Height [mm]', ylim=(0, 1), ylabel='MTF [Rel. 1.0]', title=title) ax.legend() return fig, ax
def plot_encircled_energy(self, axlim=None, npts=50, lw=config.lw, zorder=config.zorder, 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 line width zorder : `int` optional zorder 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=lw, zorder=zorder) ax.set(xlabel='Image Plane Distance [μm]', ylabel='Encircled Energy [Rel 1.0]', xlim=(0, axlim)) return fig, ax
def make_xy_grid(samples_x, samples_y=None): """Create an x, y grid from -1, 1 with n number of samples. Parameters ---------- samples_x : `int` number of samples in x direction samples_y : `int` number of samples in y direction, if None, copied from sample_x Returns ------- xx : `numpy.ndarray` x meshgrid yy : `numpy.ndarray` y meshgrid """ if samples_y is None: samples_y = samples_x x = m.linspace(-1, 1, samples_x, dtype=config.precision) y = m.linspace(-1, 1, samples_y, dtype=config.precision) xx, yy = m.meshgrid(x, y) return xx, yy
def diffraction_limited_mtf(fno, wavelength, frequencies=None, samples=128): """Give the diffraction limited MTF for a circular pupil and the given parameters. Parameters ---------- fno : `float` f/# of the lens. wavelength : `float` wavelength of light, in microns. frequencies : `numpy.ndarray` spatial frequencies of interest, in cy/mm if frequencies are given, samples is ignored. samples : `int` number of points in the output array, if frequencies not given. Returns ------- if frequencies not given: frequencies : `numpy.ndarray` array of ordinate data mtf : `numpy.ndarray` array of coordinate data else: mtf : `numpy.ndarray` array of MTF data Notes ----- If frequencies are given, just returns the MTF. If frequencies are not given, returns both the frequencies and the MTF. """ extinction = 1 / (wavelength / 1000 * fno) if frequencies is None: normalized_frequency = m.linspace(0, 1, samples) else: normalized_frequency = m.asarray(frequencies) / extinction try: normalized_frequency[normalized_frequency > 1] = 1 # clamp values except TypeError: # single freq if normalized_frequency > 1: normalized_frequency = 1 mtf = _difflim_mtf_core(normalized_frequency) if frequencies is None: return normalized_frequency * extinction, mtf else: return mtf
def thrufocus_mtf_from_wavefront(focused_wavefront, sim_params): """Create a thru-focus T/S MTF curve at each frequency requested from a focused wavefront. Parameters ---------- focused_wavefront : `Pupil` a pupil object sim_params : `SimulationConfig` a SimulationConfig namedtuple Returns ------- `pandas.DataFrame` dataframe of data Notes ----- see macros.DEFAULT_SIM_PARAMS for an example config """ import pandas as pd s = sim_params focusdiv_wvs = m.linspace(-1*s.focus_range_waves, s.focus_range_waves, s.focus_planes) focusdiv_um = defocus_to_image_displacement(focusdiv_wvs, s.fno, s.wvl, s.focus_zernike, s.focus_normed) dfs = [] for focus, displacement in zip(focusdiv_wvs, focusdiv_um): if s.focus_zernike: defocus = FringeZernike(base=1, Z4=focus, rms_norm=s.focus_normed, samples=s.samples, epd=s.efl / s.fno, wavelength=s.wvl, mask=s.mask, mask_target=s.mask_target) else: defocus = Seidel(W020=focus, epd=s.efl / s.fno, samples=s.samples, wavelength=s.wvl, mask=s.mask, mask_target=s.mask_target) mtf = MTF.from_pupil(focused_wavefront + defocus, efl=s.efl) tan, sag = mtf_ts_extractor(mtf, s.freqs) dfs.append(mtf_ts_to_dataframe(tan, sag, s.freqs, focus=displacement)) return pd.concat(dfs)
def thrufocus_mtf_from_wavefront_array(focused_wavefront, sim_params): """Create a thru-focus T/S MTF curve at each frequency requested from a focused wavefront. TODO: refactor Parameters ---------- focused_wavefront : `Pupil` a pupil object sim_params : `SimulationConfig` a SimulationConfig namedtuple Returns ------- `pandas.DataFrame` dataframe of data Notes ----- see marcros.DEFAULT_SIM_PARAMS for an example config. """ s = sim_params focusdiv_wvs = m.linspace(-s.focus_range_waves, s.focus_range_waves, s.focus_planes) tt, ss = m.empty((s.focus_planes, len(s.freqs))), m.empty((s.focus_planes, len(s.freqs))) for idx, focus in enumerate(focusdiv_wvs): if s.focus_zernike: defocus = FringeZernike(base=1, Z4=focus, rms_norm=s.focus_normed, samples=s.samples, epd=s.efl / s.fno, wavelength=s.wvl) else: defocus = Seidel(W020=focus, epd=s.efl / s.fno, samples=s.samples, wavelength=s.wvl) mtf = MTF.from_pupil(focused_wavefront + defocus, efl=s.efl) tan, sag = mtf_ts_extractor(mtf, s.freqs) tt[idx, :] = tan ss[idx, :] = sag return tt, ss
def inverted_circle(samples=128, radius=1): """ Create an inverted circular mask (obscuration). Parameters ---------- samples : `int`, optional number of samples in the square output array Returns ------ `numpy.ndarray` binary ndarray representation of the mask """ if radius is 0: return m.ones((samples, samples)) else: x = m.linspace(-1, 1, samples) y = x xx, yy = m.meshgrid(x, y) rho, phi = cart_to_polar(xx, yy) mask = m.ones(rho.shape) mask[rho < radius] = 0 return mask
def radial_mtf_to_mtfffd_data(tan, sag, imagehts, azimuths, upsample): """Take radial MTF data and map it to inputs to the MTFFFD constructor. Performs upsampling/interpolation in cartesian coordinates Parameters ---------- tan : `np.ndarray` tangential data sag : `np.ndarray` sagittal data imagehts : `np.ndarray` array of image heights azimuths : iterable azimuths corresponding to the first dimension of the tan/sag arrays upsample : `float` upsampling factor Returns ------- out_x : `np.ndarray` x coordinates of the output data out_y : `np.ndarray` y coordinates of the output data tan : `np.ndarray` tangential data sag : `np.ndarray` sagittal data """ azimuths = m.asarray(azimuths) imagehts = m.asarray(imagehts) if imagehts[0] > imagehts[-1]: # distortion profiled, values "reversed" # just flip imagehts, since spacing matters and not exact values imagehts = imagehts[::-1] amin, amax = min(azimuths), max(azimuths) imin, imax = min(imagehts), max(imagehts) aq = m.linspace(amin, amax, int(len(azimuths) * upsample)) iq = m.linspace(imin, imax, int( len(imagehts) * 4)) # hard-code 4x linear upsample, change later aa, ii = m.meshgrid(aq, iq, indexing='ij') # for each frequency, build an interpolating function and upsample up_t = m.empty((len(aq), tan.shape[1], len(iq))) up_s = m.empty((len(aq), sag.shape[1], len(iq))) for idx in range(tan.shape[1]): t, s = tan[:, idx, :], sag[:, idx, :] interpft = RGI((azimuths, imagehts), t, method='linear') interpfs = RGI((azimuths, imagehts), s, method='linear') up_t[:, idx, :] = interpft((aa, ii)) up_s[:, idx, :] = interpfs((aa, ii)) # compute the locations of the samples on a cartesian grid xd, yd = m.outer(m.cos(m.radians(aq)), iq), m.outer(m.sin(m.radians(aq)), iq) samples = m.stack([xd.ravel(), yd.ravel()], axis=1) # for the output cartesian grid, figure out the x-y coverage and build a regular grid absamin = min(abs(azimuths)) closest_to_90 = azimuths[m.argmin(azimuths - 90)] xfctr = m.cos(m.radians(absamin)) yfctr = m.cos(m.radians(closest_to_90)) xmin, xmax = imin * xfctr, imax * xfctr ymin, ymax = imin * yfctr, imax * yfctr xq, yq = m.linspace(xmin, xmax, len(iq)), m.linspace(ymin, ymax, len(iq)) xx, yy = m.meshgrid(xq, yq) outt, outs = [], [] # for each frequency, interpolate onto the cartesian grid for idx in range(up_t.shape[1]): datt = griddata(samples, up_t[:, idx, :].ravel(), (xx, yy), method='linear') dats = griddata(samples, up_s[:, idx, :].ravel(), (xx, yy), method='linear') outt.append(datt.reshape(xx.shape)) outs.append(dats.reshape(xx.shape)) outt, outs = m.rollaxis(m.asarray(outt), 0, 3), m.rollaxis(m.asarray(outs), 0, 3) return xq, yq, outt, outs
def __init__(self, samples=128, dia=1.0, wavelength=0.55, opd_unit='waves', mask='circle', mask_target='both', ux=None, uy=None, phase=None): """Create a new `Pupil` instance. Parameters ---------- samples : `int`, optional number of samples across the pupil interior dia : `float`, optional diameter of the pupil, mm wavelength : `float`, optional wavelength of light, um opd_unit : `str`, optional, {'waves', 'um', 'nm'} unit used to m.express the OPD. Equivalent strings may be used to the valid options, e.g. 'microns', or 'nanometers' mask : `str` or `numpy.ndarray` mask used to define the amplitude and boundary of the pupil; any regular polygon from `prysm.geometry` as a string, e.g. 'circle' is valid. A user-provided ndarray can also be used. mask_target : `str`, {'phase', 'fcn', 'both', None} which array to mask during pupil creation; only masking fcn is faster for numerical propagations but will make plot2d() and the phase array not be truncated properly. Note that if the mask is not binary and `phase` or `both` is used, phase plots will also not be correct, as they will be attenuated by the mask. ux : `np.ndarray` x axis units uy : `np.ndarray` y axis units phase : `np.ndarray` phase data Notes ----- If ux give, assume uy and phase also given; skip much of the pupil building process and simply copy values. Raises ------ ValueError if the OPD unit given is invalid """ if ux is None: # must build a pupil self.dia = dia ux = m.linspace(-dia / 2, dia / 2, samples) uy = m.linspace(-dia / 2, dia / 2, samples) self.samples = samples need_to_build = True else: # data already known need_to_build = False super().__init__(unit_x=ux, unit_y=uy, phase=phase, wavelength=wavelength, phase_unit=opd_unit, spatial_unit='mm') self.xaxis_label = 'Pupil ξ' self.yaxis_label = 'Pupil η' self.zaxis_label = 'OPD' self.rho = self.phi = None if need_to_build: if type(mask) is not m.ndarray: mask = mcache(mask, self.samples) self._mask = mask self.mask_target = mask_target self.build() self.mask(self._mask, self.mask_target) else: protomask = m.isnan(phase) mask = m.ones(protomask.shape) mask[protomask] = 0 self._mask = mask self.mask_target = 'fcn'