Source code for laserbeamsize.display

"""
A module for generating a graphical analysis of beam size fitting.

Full documentation is available at <https://laserbeamsize.readthedocs.io>

A graphic showing the image and extracted beam parameters is achieved by::

    >>> import imageio.v3 as iio
    >>> import matplotlib.pyplot as plt
    >>> import laserbeamsize as lbs
    >>>
    >>> repo = "https://github.com/scottprahl/laserbeamsize/raw/main/docs/"
    >>> image = iio.imread(repo + 't-hene.pgm')
    >>>
    >>> lbs.plot_image_analysis(image)
    >>> plt.show()

A mosaic of images might be created by::

    >>> import imageio.v3 as iio
    >>> import matplotlib.pyplot as plt
    >>> import numpy as np
    >>> import laserbeamsize as lbs
    >>>
    >>> repo = "https://github.com/scottprahl/laserbeamsize/raw/main/docs/"
    >>> z1 = np.array([168,210,280,348,414,480], dtype=float)
    >>> fn1 = [repo + "t-%dmm.pgm" % number for number in z1]
    >>> images = [iio.imread(fn) for fn in fn1]
    >>>
    >>> options = {'z':z1/1000, 'pixel_size':0.00375, 'units':'mm', 'crop':True}
    >>> lbs.plot_image_montage(images, **options, iso_noise=False)
    >>> plt.show()
"""

import numpy as np
import numpy.typing as npt
import matplotlib.image
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors

from .analysis import beam_size
from .background import iso_background, subtract_iso_background
from .image_tools import axes_arrays, crop_image_to_rect, crop_image_to_integration_rect
from .image_tools import ellipse_arrays, rotated_rect_arrays, major_axis_arrays, minor_axis_arrays

__all__ = (
    "beam_ellipticity",
    "plot_beam_diagram",
    "plot_image_analysis",
    "plot_image_and_fit",
    "plot_image_montage",
)


[docs] def beam_ellipticity(d_major: float, d_minor: float) -> tuple[float, float]: """ Calculate the ellipticity of the beam. The ISO 11146 standard defines ellipticity as the "ratio between the minimum and maximum beam widths". These widths (diameters) returned by `beam_size()` can be used to make this calculation. When `ellipticity > 0.87`, then the beam profile may be considered to have circular symmetry. The equivalent beam diameter is the root mean square of the beam diameters. Args: d_major: x diameter of the beam spot d_minor: y diameter of the beam spot Returns: ellipticity: varies from 0 (line) to 1 (round) d_circular: equivalent diameter of a circular beam """ if d_minor < d_major: ellipticity = d_minor / d_major elif d_major < d_minor: ellipticity = d_major / d_minor else: ellipticity = 1 d_circular = np.sqrt((d_major**2 + d_minor**2) / 2) return ellipticity, d_circular
[docs] def plot_beam_diagram() -> None: """Draw a simple astigmatic beam ellipse with labels.""" phi = np.radians(30) xc, yc, d_major, d_minor = 0, 0, 50, 25 plt.subplots(1, 1, figsize=(6, 6)) # If the aspect ratio is not `equal` then the major and minor axes # will not appear to be orthogonal to each other! plt.axes().set_aspect("equal") xp, yp = ellipse_arrays(xc, yc, d_major, d_minor, phi) plt.plot(xp, yp, "k", lw=2) scale = 1 diameters = 3 rect_major = d_major * diameters rect_minor = d_minor * diameters xp, yp = rotated_rect_arrays(xc, yc, rect_major, rect_minor, phi) * scale plt.plot(xp, yp, ":b", lw=2) sint = np.sin(phi) / 2 cost = np.cos(phi) / 2 plt.plot([xc - d_major * cost, xc + d_major * cost], [yc + d_major * sint, yc - d_major * sint], ":b") plt.plot([xc + d_minor * sint, xc - d_minor * sint], [yc + d_minor * cost, yc - d_minor * cost], ":r") # draw axes plt.annotate( "x", xy=(-25, 0), xytext=(25, 0), arrowprops={"arrowstyle": "<-"}, va="center", fontsize=16, ) plt.annotate( "y", xy=(0, 25), xytext=(0, -25), arrowprops={"arrowstyle": "<-"}, ha="center", fontsize=16, ) plt.annotate(r"$\phi$", xy=(13, -2.5), fontsize=16) plt.annotate( "", xy=(15.5, 0), xytext=(14, -8.0), arrowprops={"arrowstyle": "<-", "connectionstyle": "arc3, rad=-0.2"}, ) plt.annotate(r"$d_{major}$", xy=(-17, 7), color="blue", fontsize=16) plt.annotate(r"$d_{minor}$", xy=(-4, -8), color="red", fontsize=16) plt.xlim(-30, 30) plt.ylim(30, -30) # inverted to match image coordinates! plt.axis("off")
def plot_visible_dotted_line(xpts: npt.NDArray[np.floating], ypts: npt.NDArray[np.floating]) -> None: """Draw a dotted line that is is visible against images.""" # White solid line underneath plt.plot(xpts, ypts, color="white", linewidth=1, solid_capstyle="round") # Black dashes on top plt.plot(xpts, ypts, color="black", linewidth=1, linestyle=(0, (3, 2)), solid_capstyle="round") def set_zero_to_lightgray(cmap_name: str, min_val: float, max_val: float) -> mcolors.ListedColormap: """Create a colormap where zero maps to gray.""" cmap = plt.get_cmap(cmap_name) colors = cmap(np.linspace(0, 1, 256)) # index that corresponds to zero idx = 0 if min_val < 0 <= max_val: idx = int(256 * abs(min_val) / (max_val - min_val)) idx = int(np.clip(idx, 0, len(colors) - 1)) colors[idx] = [0.827, 0.827, 0.827, 1.0] return mcolors.ListedColormap(colors) def _format_beam_title(d_major: float | None, d_minor: float | None, units: str, z: float | None = None) -> str: """ Return a standardized title string describing the beam diameters. If z position is not None, then it should be specified in meters. The z position will be converted to mm for display. Args: d_major: major diameter in units specified d_minor: minor diameter in units specified units: units for diameters z: (optional) if present add z-position to title Returns: title """ def _fmt(val, label): if val is None or (isinstance(val, (float, np.floating)) and np.isnan(val)): return f"{label} fail" if units == "mm": return f"{label}={val:.2f}{units}" return f"{label}={val:.0f}{units}" s1 = _fmt(d_major, "$d_{major}$") s2 = _fmt(d_minor, "$d_{minor}$") s = f"{s1}, {s2}" if z is None: return s # z is in meters; display in mm as in the montage return f"z={z * 1e3:.0f}mm, {s}" def _prepare_beam_analysis( image: np.ndarray, corner_fraction: float, nT: float, iso_noise: bool, **kwargs ) -> tuple[float, float, float, float, float | None, float]: """ Common setup for beam analysis: extract beam_size parameters and calculate beam properties. Args: image: 2D array of image with beam spot corner_fraction: the fractional size of corner rectangles nT: how many standard deviations to subtract iso_noise: if True then allow negative pixel values **kwargs: extra options to pass to beam_size() Returns: tuple: (diameters, xc_px, yc_px, d_major_px, d_minor_px, phi) """ diameters = kwargs.get("mask_diameters", 3) # only pass along arguments that apply to beam_size() beamsize_keys = ["mask_diameters", "max_iter", "phi_fixed"] bs_args = dict((k, kwargs[k]) for k in beamsize_keys if k in kwargs) bs_args["iso_noise"] = iso_noise bs_args["nT"] = nT bs_args["corner_fraction"] = corner_fraction # find center and diameters (all in pixels) xc_px, yc_px, d_major_px, d_minor_px, phi = beam_size(image, **bs_args) return diameters, xc_px, yc_px, d_major_px, d_minor_px, phi def _setup_scale_and_labels(pixel_size: float | None, units: str) -> tuple[float, str, str]: """ Determine scaling factor and axis labels. Args: pixel_size: size of pixels (None for pixel units) units: string for physical units Returns: tuple: (scale, label, units_str) """ if pixel_size is None: scale: float = 1 unit_str = "px" else: scale = pixel_size unit_str = units label = "Distance from Center [%s]" % unit_str return scale, label, unit_str def _crop_image_if_needed( o_image: np.ndarray, xc_px: float, yc_px: float, d_major_px: float, d_minor_px: float | None, phi: float, crop: bool | list, scale: float, diameters: float, ) -> tuple[np.ndarray, float, float]: """ Crop the image according to the crop parameter. Args: o_image: original image xc_px: beam center coordinates (in pixels) yc_px: beam center coordinates (in pixels) d_major_px: major beam diameter (in pixels) d_minor_px: minor beam diameter (in pixels) phi: angle tilt (in radians) crop: cropping specification (False, True, or [v, h] list) scale: pixel scaling factor diameters: number of diameters for integration rectangle Returns: tuple: (cropped_image, new_xc_px, new_yc_px) """ if isinstance(crop, list): ymin = yc_px - crop[0] / 2 / scale ymax = yc_px + crop[0] / 2 / scale xmin = xc_px - crop[1] / 2 / scale xmax = xc_px + crop[1] / 2 / scale cropped, new_xc, new_yc = crop_image_to_rect(o_image, xc_px, yc_px, xmin, xmax, ymin, ymax) if cropped is None or new_xc is None or new_yc is None: return o_image, xc_px, yc_px return cropped, new_xc, new_yc if crop: return crop_image_to_integration_rect(o_image, xc_px, yc_px, d_major_px, d_minor_px, phi, diameters) return o_image, xc_px, yc_px def _draw_beam_overlays( xc_px: float, yc_px: float, d_major_px: float, d_minor_px: float | None, phi: float, diameters: float, scale: float ) -> None: """ Draw ellipse, axes, and integration rectangle on current plot. Args: xc_px: beam center coordinates (in pixels) yc_px: beam center coordinates (in pixels) d_major_px: major beam diameter (in pixels) d_minor_px: minor beam diameter (in pixels) phi: angle tilt (in radians) diameters: number of diameters for integration rectangle scale: pixel scaling factor """ # Calculate rectangle dimensions (in pixels) rect_minor_px = None rect_major_px = d_major_px * diameters - 0.5 # -0.5 centres the rect on the pixel grid if d_minor_px is not None: rect_minor_px = d_minor_px * diameters - 0.5 # -0.5 centres the rect on the pixel grid # Draw ellipse around beam xp_px, yp_px = ellipse_arrays(xc_px, yc_px, d_major_px, d_minor_px, phi) plot_visible_dotted_line((xp_px - xc_px) * scale, (yp_px - yc_px) * scale) # Draw integration rectangle around beam xp_px, yp_px = rotated_rect_arrays(xc_px - 0.5, yc_px - 0.5, rect_major_px, rect_minor_px, phi) plot_visible_dotted_line((xp_px - xc_px) * scale, (yp_px - yc_px) * scale) # major and minor axes xp1_px, yp1_px, xp2_px, yp2_px = axes_arrays(xc_px, yc_px, rect_major_px, rect_minor_px, phi) plot_visible_dotted_line((xp1_px - xc_px) * scale, (yp1_px - yc_px) * scale) if d_minor_px is not None and xp2_px is not None and yp2_px is not None: plot_visible_dotted_line((xp2_px - xc_px) * scale, (yp2_px - yc_px) * scale) def _plot_image_with_beam_overlay( image: np.ndarray, xc_px: float, yc_px: float, d_major_px: float, d_minor_px: float | None, phi: float, diameters: float, scale: float, label: str, cmap: str, vmin: float | None = None, vmax: float | None = None, title: str | None = None, colorbar: bool = True, ) -> matplotlib.image.AxesImage: """ Core function to plot an image with beam overlays. Used by both plot_image_and_fit and plot_image_analysis. Args: image: 2D image to display xc_px: beam center in pixels yc_px: beam center in pixels d_major_px: major diameter in pixels d_minor_px: minor diameter in pixels phi: beam angle in radians diameters: integration rectangle size multiplier scale: pixel to unit conversion label: axis label string cmap: colormap vmin: optional colorbar minimum vmax: optional colorbar maximum title: optional plot title colorbar: whether to show colorbar Returns: im: the image object """ v_px, h_px = image.shape extent = (-xc_px * scale, (h_px - xc_px) * scale, (v_px - yc_px) * scale, -yc_px * scale) # establish colorbar limits if vmax is None: vmax = image.max() if vmin is None: vmin = image.min() # add gray to cmap around zero ccmap = set_zero_to_lightgray(cmap, vmin, vmax) # display image im = plt.imshow(image, extent=extent, cmap=ccmap, vmax=vmax, vmin=vmin) im.cmap.set_bad(color="black") plt.xlabel(label) plt.ylabel(label) # Draw beam overlays (ellipse, axes, integration rectangle) _draw_beam_overlays(xc_px, yc_px, d_major_px, d_minor_px, phi, diameters, scale) # set limits on axes plt.xlim(-xc_px * scale, (h_px - xc_px) * scale) plt.ylim((v_px - yc_px) * scale, -yc_px * scale) if title: plt.title(title) # show colorbar if colorbar: plt.colorbar(im, fraction=0.046 * v_px / h_px, pad=0.04) return im
[docs] def plot_image_and_fit( o_image: np.ndarray, pixel_size: float | None = None, vmin: float | None = None, vmax: float | None = None, units: str = "µm", crop: bool | list = False, colorbar: bool = False, cmap: str = "gist_ncar", corner_fraction: float = 0.035, nT: float = 3, iso_noise: bool = True, **kwargs, ) -> tuple[float, float, float, float | None, float]: """ Plot the image, fitted ellipse, integration area, and major/minor axes. If pixel_size is defined, then the returned measurements are in units of pixel_size. This function helpful when creating a mosaics of all images captured for an experiment. If `crop==True` then the displayed image is cropped to the ISO 11146 integration rectangle. If `crop` is a two parameter list `[v, h]` then `v` and `h` are interpreted as the vertical and horizontal sizes of the rectangle. The size is in pixels unless `pixel_size` is specified. In that case the rectangle sizes are in whatever units `pixel_size` is . All cropping is done after analysis and therefore only affects what is displayed. If the image needs to be cropped before analysis then that must be done before calling this function. Args: o_image: 2D array of image with beam spot pixel_size: (optional) size of pixels vmin: (optional) minimum value for colorbar vmax: (optional) maximum value for colorbar units: (optional) string used for units used on axes crop: (optional) crop image to integration rectangle colorbar: (optional) show the color bar, cmap: (optional) colormap to use corner_fraction: (optional) the fractional size of corner rectangles nT: (optional) how many standard deviations to subtract iso_noise: (optional) if True then allow negative pixel values kwargs: additional arguments passed through to beam_size Returns: xc, yc, d_major, d_minor, phi """ # Common beam analysis setup diameters, xc_px, yc_px, d_major_px, d_minor_px, phi = _prepare_beam_analysis( o_image, corner_fraction, nT, iso_noise, **kwargs ) # Setup scale and labels scale, label, unit_str = _setup_scale_and_labels(pixel_size, units) # Crop image if necessary (analysis is already done on o_image) image, xc_px, yc_px = _crop_image_if_needed( o_image, xc_px, yc_px, d_major_px, d_minor_px, phi, crop, scale, diameters ) # For display, use the same ISO-11146 background subtraction used by # plot_image_analysis (subplot 2,2,2). This drives the background toward # zero so that the colormap can put zero at gray. working_image = subtract_iso_background(image, corner_fraction=corner_fraction, nT=nT, iso_noise=iso_noise) # Convert diameters to the requested units for the title d_major = d_major_px * scale d_minor = d_minor_px * scale if d_minor_px is not None else None title = _format_beam_title(d_major, d_minor, unit_str) # Use helper function for plotting; vmin/vmax still honored but now apply # to the background-subtracted image. _plot_image_with_beam_overlay( working_image, xc_px, yc_px, d_major_px, d_minor_px, phi, diameters, scale, label, cmap, vmin, vmax, title=title, colorbar=colorbar, ) if d_minor_px is not None: ds = d_minor_px * scale else: ds = None return xc_px * scale, yc_px * scale, d_major_px * scale, ds, phi
[docs] def plot_image_analysis( o_image: np.ndarray, title: str = "Original", pixel_size: float | None = None, units: str = "µm", crop: bool | list = False, cmap: str = "gist_ncar", corner_fraction: float = 0.035, nT: float = 3, iso_noise: bool = True, **kwargs, ) -> None: """ Create a visual report for image fitting. If `crop` is a two parameter list `[v, h]` then `v` and `h` are interpreted as the vertical and horizontal sizes of the rectangle. The size is in pixels unless `pixel_size` is specified. In that case the rectangle sizes are in whatever units `pixel_size` is . If `crop==True` then the displayed image is cropped to the ISO 11146 integration rectangle. All cropping is done after analysis and therefore only affects what is displayed. If the image needs to be cropped before analysis then that must be done before calling this function. Args: o_image: 2D image of laser beam title: (optional) title for upper left plot pixel_size: (optional) size of pixels units: (optional) string used for units used on axes crop: (optional) crop image to integration rectangle cmap: (optional) colormap to use corner_fraction: (optional) the fractional size of corner rectangles nT: (optional) how many standard deviations to subtract iso_noise: if True then allow negative pixel values **kwargs: extra options to modify display Returns: nothing """ # Common beam analysis setup diameters, xc_px, yc_px, d_major_px, d_minor_px, phi = _prepare_beam_analysis( o_image, corner_fraction, nT, iso_noise, **kwargs ) # Setup scale and labels scale, label, units_str = _setup_scale_and_labels(pixel_size, units) # Crop image if necessary image, xc_px, yc_px = _crop_image_if_needed( o_image, xc_px, yc_px, d_major_px, d_minor_px, phi, crop, scale, diameters ) # subtract background working_image = subtract_iso_background(image, corner_fraction=corner_fraction, nT=nT, iso_noise=iso_noise) bkgnd, _ = iso_background(image, corner_fraction=corner_fraction, nT=nT) min_ = image.min() max_ = image.max() vv_px, hh_px = image.shape # scale all the dimensions v_s = vv_px * scale h_s = hh_px * scale r_major_s = d_major_px * scale / 2 plt.subplots(2, 2, figsize=(12, 12)) plt.subplots_adjust(right=1.0) # add gray to cmap around zero ccmap = set_zero_to_lightgray(cmap, min_, max_) # original image plt.subplot(2, 2, 1) im = plt.imshow(image, cmap=ccmap) im.cmap.set_bad(color="black") # color for padded values plt.colorbar(im, fraction=0.046 * v_s / h_s, pad=0.04) plt.clim(min_, max_) plt.xlabel("Position [px]") plt.ylabel("Position [px]") plt.title(title + ", center at (%.0f, %.0f) px" % (xc_px, yc_px)) # working image plt.subplot(2, 2, 2) # diameters in display units for a consistent title d_major = d_major_px * scale if d_major_px is not None else None d_minor = d_minor_px * scale if d_minor_px is not None else None work_title = _format_beam_title(d_major, d_minor, units_str) _plot_image_with_beam_overlay( working_image, xc_px, yc_px, d_major_px, d_minor_px, phi, diameters, scale, label, cmap, vmin=None, vmax=None, title=work_title, colorbar=True, ) extra = 1.03 # find max and min for both plots so that both plots have equal sizes rect_major_px = d_major_px * diameters _, _, z_major, s_major_px = major_axis_arrays(image, xc_px, yc_px, rect_major_px, phi) a_major = np.sqrt(8 / np.pi) / d_major_px * abs(np.sum(z_major - bkgnd) * (s_major_px[1] - s_major_px[0])) a_minor: float = 0 z_minor = np.array([0]) r_minor_s: float = 0 if d_minor_px is not None: r_minor_s = d_minor_px * scale / 2 rect_minor_px = d_minor_px * diameters _, _, z_minor, s_minor_px = minor_axis_arrays(image, xc_px, yc_px, rect_minor_px, phi) a_minor = np.sqrt(8 / np.pi) / d_minor_px * abs(np.sum(z_minor - bkgnd) * (s_minor_px[1] - s_minor_px[0])) baseline = float(a_major) * np.exp(-2 * (diameters / 2) ** 2) + bkgnd base_e2 = float(a_major) * np.exp(-2) + bkgnd z_min = 0 z_max = np.max([a_major, np.max(z_major), a_minor, np.max(z_minor)]) * extra + baseline plt.subplot(2, 2, 3) plt.plot(s_major_px * scale, z_major, "sb", markersize=2) plt.plot(s_major_px * scale, z_major, "-b", lw=0.5) # gaussian and label z_values = bkgnd + a_major * np.exp(-8 * (s_major_px / d_major_px) ** 2) plt.plot(s_major_px * scale, z_values, "k") plt.text(0, bkgnd + a_major, " Gaussian Fit") # double arrow and label plt.annotate("", (-r_major_s, base_e2), (r_major_s, base_e2), arrowprops={"arrowstyle": "<->"}) if r_major_s < max(s_major_px) * scale / 2: plt.text(r_major_s, base_e2, " $d_{major}$=%.0f %s" % (2 * r_major_s, units_str), va="center", ha="left") else: plt.text(0, 1.1 * base_e2, "$d_{major}$=%.0f %s" % (2 * r_major_s, units_str), va="bottom", ha="center") plt.xlabel("Distance from Center [%s]" % units_str) plt.ylabel("Pixel Value") plt.title("Major Axis") plt.ylim(z_min, z_max) plt.xlim(min(s_major_px) * scale, max(s_major_px) * scale) if d_minor_px is not None: # plot of values along minor axis plt.subplot(2, 2, 4) plt.plot(s_minor_px * scale, z_minor, "sb", markersize=2) plt.plot(s_minor_px * scale, z_minor, "-b", lw=0.5) z_values = bkgnd + a_minor * np.exp(-8 * (s_minor_px / d_minor_px) ** 2) plt.plot(s_minor_px * scale, z_values, "k") # double arrow and label plt.annotate("", (-r_minor_s, base_e2), (r_minor_s, base_e2), arrowprops={"arrowstyle": "<->"}) if r_minor_s < max(s_minor_px) * scale / 2: plt.text( r_minor_s, base_e2, " $d_{minor}$=%.0f %s" % (2 * r_minor_s, units_str), va="center", ha="left", ) else: plt.text(0, 1.1 * base_e2, "$d_{minor}$=%.0f %s" % (2 * r_minor_s, units_str), va="bottom", ha="center") plt.text(0, bkgnd + a_minor, " Gaussian Fit") plt.xlabel("Distance from Center [%s]" % units_str) plt.ylabel("Pixel Value") plt.title("Minor Axis") plt.ylim(z_min, z_max) plt.xlim(min(s_minor_px) * scale, max(s_minor_px) * scale) else: plt.subplot(2, 2, 4) plt.text(0.5, 0.5, "Fit failed.", ha="center", va="center") # add more horizontal space between plots plt.subplots_adjust(wspace=0.3)
[docs] def plot_image_montage( images: list[np.ndarray], z: npt.NDArray[np.floating] | None = None, cols: int = 3, pixel_size: float | None = None, vmax: float | None = None, vmin: float | None = None, units: str = "µm", crop: bool | list = False, cmap: str = "gist_ncar", corner_fraction: float = 0.035, nT: float = 3, iso_noise: bool = True, **kwargs, ) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]: """ Create a beam size montage for a set of images. If `crop` is a two parameter list `[v, h]` then `v` and `h` are interpreted as the vertical and horizontal sizes of the rectangle. The size is in pixels unless `pixel_size` is specified. In that case the rectangle sizes are in whatever units `pixel_size` is . If `crop==True` then the displayed image is cropped to the ISO 11146 integration rectangle. All cropping is done after analysis and therefore only affects what is displayed. If the image needs to be cropped before analysis then that must be done before calling this function. Args: images: array of 2D images of the laser beam z: (optional) array of axial positions of images (always in meters!) cols: (optional) number of columns in the montage pixel_size: (optional) size of pixels vmax: (optional) maximum gray level to use vmin: (optional) minimum gray level to use units: (optional) string used for units used on axes crop: (optional) crop image to integration rectangle cmap: (optional) colormap to use corner_fraction: (optional) the fractional size of corner rectangles nT: (optional) how many standard deviations to subtract iso_noise: (optional) if True then allow negative pixel values **kwargs: (optional) extra options to modify display Returns: d_major: major axis (i.e, major diameter) d_minor: minor axis (i.e, minor diameter) """ # arrays to save diameters d_major = np.zeros(len(images)) d_minor = np.zeros(len(images)) # calculate the number of rows needed in the montage rows = (len(images) - 1) // cols + 1 # when pixel_size is not specified, units default to pixels if pixel_size is None: units = "px" # gather all the options that are fixed for every image in the montage options = { "pixel_size": pixel_size, "vmax": vmax, "vmin": vmin, "units": units, "crop": crop, "cmap": cmap, "corner_fraction": corner_fraction, "nT": nT, "iso_noise": iso_noise, **kwargs, } # now set up the grid of subplots plt.subplots(rows, cols, figsize=(cols * 5, rows * 5)) for i, im in enumerate(images): plt.subplot(rows, cols, i + 1) # should we add color bar? cb = vmax is not None and (i + 1 == cols) # plot the image and gather the beam diameters _, _, d_major[i], d_minor[i], _ = plot_image_and_fit(im, **options, colorbar=cb) # add a title using the shared formatter if z is None: title = _format_beam_title(d_major[i], d_minor[i], units) else: title = _format_beam_title(d_major[i], d_minor[i], units, z=z[i]) plt.title(title) # omit y-labels on all but first column if i % cols: plt.ylabel("") if isinstance(crop, list): plt.yticks([]) # omit x-labels on all but last row if i < (rows - 1) * cols: plt.xlabel("") if isinstance(crop, list): plt.xticks([]) for i in range(len(images), rows * cols): plt.subplot(rows, cols, i + 1) plt.axis("off") return d_major, d_minor