Source code for nexusLIMS.extractors.thumbnail_generator

#  NIST Public License - 2019
#
#  This software was developed by employees of the National Institute of
#  Standards and Technology (NIST), an agency of the Federal Government
#  and is being made available as a public service. Pursuant to title 17
#  United States Code Section 105, works of NIST employees are not subject
#  to copyright protection in the United States.  This software may be
#  subject to foreign copyright.  Permission in the United States and in
#  foreign countries, to the extent that NIST may hold copyright, to use,
#  copy, modify, create derivative works, and distribute this software and
#  its documentation without fee is hereby granted on a non-exclusive basis,
#  provided that this notice and disclaimer of warranty appears in all copies.
#
#  THE SOFTWARE IS PROVIDED 'AS IS' WITHOUT ANY WARRANTY OF ANY KIND,
#  EITHER EXPRESSED, IMPLIED, OR STATUTORY, INCLUDING, BUT NOT LIMITED
#  TO, ANY WARRANTY THAT THE SOFTWARE WILL CONFORM TO SPECIFICATIONS, ANY
#  IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE,
#  AND FREEDOM FROM INFRINGEMENT, AND ANY WARRANTY THAT THE DOCUMENTATION
#  WILL CONFORM TO THE SOFTWARE, OR ANY WARRANTY THAT THE SOFTWARE WILL BE
#  ERROR FREE.  IN NO EVENT SHALL NIST BE LIABLE FOR ANY DAMAGES, INCLUDING,
#  BUT NOT LIMITED TO, DIRECT, INDIRECT, SPECIAL OR CONSEQUENTIAL DAMAGES,
#  ARISING OUT OF, RESULTING FROM, OR IN ANY WAY CONNECTED WITH THIS SOFTWARE,
#  WHETHER OR NOT BASED UPON WARRANTY, CONTRACT, TORT, OR OTHERWISE, WHETHER
#  OR NOT INJURY WAS SUSTAINED BY PERSONS OR PROPERTY OR OTHERWISE, AND
#  WHETHER OR NOT LOSS WAS SUSTAINED FROM, OR AROSE OUT OF THE RESULTS OF,
#  OR USE OF, THE SOFTWARE OR SERVICES PROVIDED HEREUNDER.
#
"""
Generate preview images from various data files.

Data files are represented as either HyperSpy Signals, or as raw data files
(in the case of tiff images)
"""
import logging
import shutil
import tempfile
import textwrap
from pathlib import Path
from typing import Optional, Tuple, Union

import hyperspy.api as hs_api
import matplotlib as mpl
import matplotlib.pyplot as plt
import numpy as np
from hyperspy.drawing.marker import dict2marker
from matplotlib.figure import Figure
from matplotlib.offsetbox import AnchoredOffsetbox, OffsetImage
from matplotlib.transforms import Bbox
from PIL import Image, UnidentifiedImageError
from skimage import transform
from skimage.io import imread
from skimage.transform import resize

try:
    _LANCZOS = Image.Resampling.LANCZOS
except AttributeError:  # pragma: no cover
    # above is deprecated as of Pillow 9.1.0
    _LANCZOS = Image.LANCZOS

logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
mpl.use("Agg")


def _full_extent(axis, items, pad=0.0):
    """
    Get the full extent of items in an axis.

    Adapted from https://stackoverflow.com/a/26432947/1435788.
    """
    # For text objects, we need to draw the figure first, otherwise the extents
    # are undefined.
    axis.figure.canvas.draw()
    bbox = Bbox.union([item.get_window_extent() for item in items])

    return bbox.expanded(1.0 + pad, 1.0 + pad)


def _set_title(axis, title):
    """
    Set an axis title with maximum width.

    Makes sure it is no wider than 60 characters (so it doesn't run over the edges
    of the plot)

    Parameters
    ----------
    ax : :py:mod:`matplotlib.axis`
        A matplotlib axis instance on which to operate
    title : str
        The desired axis title
    """
    new_title = textwrap.fill(title, 60)
    axis.set_title(new_title)


def _get_visible_labels(axis):
    """
    Get labels that are visible for plot.

    Helper method to return only the tick labels that are visible given the
    current extent of the axes. Useful when calculating the extent of the figure
    to save so extra white space from invisible labels is not included.

    Parameters
    ----------
    ax : :py:mod:`matplotlib.axis`
        A matplotlib axis instance on which to operate

    Returns
    -------
    vis_labels_x, vis_labels_y : tuple of lists
        lists of only the label objects that are visible on the current axis
    """
    vis_labels_x = mpl.cbook.silent_list("Text xticklabel")
    vis_labels_y = mpl.cbook.silent_list("Text yticklabel")

    for label in axis.get_xticklabels():
        label_pos = label.get_position()[0]
        x_limits = axis.get_xlim()
        if x_limits[0] < label_pos < x_limits[1]:
            vis_labels_x.append(label)
    for label in axis.get_yticklabels():
        label_pos = label.get_position()[1]
        y_limits = axis.get_ylim()
        if y_limits[0] < label_pos < y_limits[1]:
            vis_labels_y.append(label)

    return vis_labels_x, vis_labels_y


def _project_image_stack(s, num=5, dpi=92, v_shear=0.3, h_scale=0.3):
    """
    Project an image stack.

    Create a preview of an image stack by selecting a number of example frames
    and projecting them into a pseudo-3D display.

    Parameters
    ----------
    s : :py:class:`hyperspy.signal.BaseSignal` (or subclass)
        The HyperSpy signal for which an image stack preview should be
        generated. Should have a signal dimension of 2 and a navigation
        dimension of 1.
    num : int
        The number of frames in the image stack to use to make the preview
    dpi : int
        The "dots per inch" of the individual frames within the preview
    v_shear : float
        The factor by which to vertically shear (0.5 means shear the top border
        down by half of the original image's height)
    h_scale : float
        The factor by which to scale in the horizontal direction (0.3 means
        each projected frame will be 30% the width of the original image)

    Returns
    -------
    output : :py:class:`numpy.ndarray`
        The `num` frames loaded into a single NumPy array for plotting
    """
    tmps = []
    for i in np.linspace(0, s.axes_manager.navigation_size - 1, num=num, dtype=int):
        hs_api.plot.plot_images(
            [s.inav[i].as_signal2D((0, 1))],
            axes_decor="off",
            colorbar=False,
            scalebar="all",
            label=None,
        )
        tmp = tempfile.NamedTemporaryFile()  # pylint: disable=consider-using-with
        axis = plt.gca()
        axis.set_position([0, 0, 1, 1])
        axis.set_axis_on()
        for axis_side in ["top", "bottom", "left", "right"]:
            axis.spines[axis_side].set_linewidth(5)
        axis.figure.canvas.draw()
        axis.figure.savefig(tmp.name + ".png", dpi=dpi)
        tmps.append(tmp)
        plt.close(axis.figure)

    im_data = []
    for tmp in tmps:
        img = plt.imread(tmp.name + ".png")
        img_trans = transform.warp(
            image=img,
            inverse_map=np.dot(
                np.array([[1, 0, 0], [-1 * v_shear, 1, 0], [0, 0, 1]]),  # shear
                np.linalg.inv(
                    np.array([[h_scale, 0, 0], [0, 1, 0], [0, 0, 1]]),
                ),  # scale
            ),
            order=1,
            preserve_range=True,
            mode="constant",
            cval=np.nan,
            output_shape=(
                int(img.shape[1] * (1 + v_shear)),
                int(img.shape[0] * h_scale),
            ),
        )
        im_data.append(img_trans)

    for temp_file in tmps:
        temp_file.close()
        Path(temp_file.name + ".png").unlink()

    return np.hstack(im_data)


def _pad_to_square(im_path: Path, new_width: int = 500):
    """
    Pad an image to square.

    Helper method to pad an image saved on disk to a square with size
    ``width x width``. This ensures consistent display on the front-end web
    page. Increasing the size of a dimension is done by padding with empty
    space. The original image is overwritten.

    Method adapted from:
    https://jdhao.github.io/2017/11/06/resize-image-to-square-with-padding/

    Parameters
    ----------
    im_path
        The path to the image that should be resized/padded
    new_width
        Desired output width/height of the image (in pixels)
    """
    image = Image.open(im_path)
    old_size = image.size  # old_size[0] is in (width, height) format
    ratio = float(new_width) / max(old_size)
    new_size = tuple(int(x * ratio) for x in old_size)
    image = image.resize(new_size, _LANCZOS)

    new_im = Image.new("RGBA", (new_width, new_width))
    new_im.paste(
        image,
        ((new_width - new_size[0]) // 2, (new_width - new_size[1]) // 2),
    )
    new_im.save(im_path)


def _get_marker_color(annotation):
    """
    Get the color of a DigitalMicrograph annotation.

    Parameters
    ----------
    annotation : dict
        The tag dictionary for a given annotation from a DigitalMicrograph
        tag structure

    Returns
    -------
    color : str or tuple
        Either an RGB tuple, or string containing a color name
    """
    if ("ForegroundColor" in annotation) or ("Color" in annotation):
        # There seems to be 3 different colors in annotations in
        # dm3-files: Color, ForegroundColor and BackgroundColor.
        # ForegroundColor and BackgroundColor seems to be present
        # for all annotations. Color is present in some of them.
        # If Color is present, it seems to override the others.
        # Currently, BackgroundColor is not utilized, due to
        # HyperSpy markers only supporting a single color.
        if "Color" in annotation:
            color_raw = annotation["Color"]
        else:
            color_raw = annotation["ForegroundColor"]
        # Colors in DM are saved as negative values
        # Some values are also in 16-bit
        color = []
        for raw_value in color_raw:
            raw_value_ = abs(raw_value)
            if raw_value_ > 1:
                raw_value_ /= 2**16
            color.append(raw_value_)
        color = tuple(color)
    else:
        color = "red"

    return color


def _get_marker_props(annotation):
    """
    Get the properties of a DigitalMicrograph annotation.

    Parameters
    ----------
    annotation : dict
        The tag dictionary for a given annotation from a DigitalMicrograph
        tag structure

    Returns
    -------
    marker_properties : dict
        A dictionary containing various properties for this
        annotation/marker, such as line width, style, etc.
    temp_dict : dict
        A dictionary that contains the marker type
    marker_text : None or str
        If present, the text of a textual annotation
    """
    marker_properties = {}
    temp_dict = {}
    marker_text = None
    log_msg_map = {
        3: "Arrow marker not loaded: not implemented",
        4: "Double arrow marker not loaded: not implemented",
        6: "Ellipse marker not loaded: not implemented",
        8: "Mask spot marker not loaded: not implemented",
        9: "Mask array marker not loaded: not implemented",
        15: "Mask band pass marker not loaded: not implemented",
        19: "Mask wedge marker not loaded: not implemented",
        29: "ROI curve marker not loaded: not implemented",
        31: "Scalebar marker not loaded: not implemented",
    }
    line_segment_type = 2
    rectangle_type = 5
    text_type = 13
    roi_rectangle_type = 23
    roi_line_type = 25
    point_type = 27
    if "AnnotationType" in annotation:
        annotation_type = annotation["AnnotationType"]
        if annotation_type == line_segment_type:
            temp_dict["marker_type"] = "LineSegment"
            marker_properties["linewidth"] = 2
        elif annotation_type == rectangle_type:
            temp_dict["marker_type"] = "Rectangle"
            marker_properties["linewidth"] = 2
        elif annotation_type == text_type:
            temp_dict["marker_type"] = "Text"
            marker_text = annotation["Text"]
        elif annotation_type == roi_rectangle_type:  # roirectangle
            temp_dict["marker_type"] = "Rectangle"
            marker_properties["linestyle"] = "--"
            marker_properties["linewidth"] = 2
        elif annotation_type == roi_line_type:  # roiline
            temp_dict["marker_type"] = "LineSegment"
            marker_properties["linestyle"] = "--"
            marker_properties["linewidth"] = 2
        elif annotation_type == point_type:
            temp_dict["marker_type"] = "Point"
        elif annotation_type in log_msg_map:
            logger.debug(log_msg_map[annotation_type])

    return marker_properties, temp_dict, marker_text


def _get_markers_dict(s, tags_dict):
    """
    Get dictionary of markers from a HyperSpy signal.

    Parameters
    ----------
    s : :py:class:`hyperspy.signal.BaseSignal`
        The HyperSpy signal from which annotations should be read
    tags_dict : dict
        The dictionary of DigitalMicrograph tags (saved as
        ``s.original_metadata``)

    Returns
    -------
    markers_dict : dict
        The Markers that correspond to the annotations found in `s`
    """
    scale = {"x": s.axes_manager["x"].scale, "y": s.axes_manager["y"].scale}
    offset = {"x": s.axes_manager["x"].offset, "y": s.axes_manager["y"].offset}

    markers_dict = {}
    annotations_dict = tags_dict["DocumentObjectList"]["TagGroup0"][
        "AnnotationGroupList"
    ]
    for annotation in annotations_dict.values():
        if "Rectangle" in annotation:
            position = annotation["Rectangle"]
        marker_properties, temp_dict, marker_text = _get_marker_props(annotation)
        if "marker_type" in temp_dict:
            color = _get_marker_color(annotation)
            if "Label" in annotation and annotation["Label"] != []:
                # Some annotations contains an empty label, which are
                # represented in the input dict as an empty list: []
                marker_label = annotation["Label"]
                label_marker_dict = {
                    "marker_type": "Text",
                    "plot_marker": True,
                    "plot_on_signal": True,
                    "axes_manager": s.axes_manager,
                    "data": {
                        "y1": position[0] * scale["y"] + offset["y"],
                        "x1": position[1] * scale["x"] + offset["x"],
                        "size": 20,
                        "text": marker_label,
                    },
                    "marker_properties": {
                        "color": color,
                        "va": "bottom",
                    },
                }
                marker_name = "Text" + str(annotation["UniqueID"])
                markers_dict[marker_name] = label_marker_dict

            marker_properties["color"] = color
            temp_dict["plot_on_signal"] = (True,)
            temp_dict["plot_marker"] = (True,)
            temp_dict["axes_manager"] = (s.axes_manager,)
            temp_dict["data"] = {
                "y1": position[0] * scale["y"] + offset["y"],
                "x1": position[1] * scale["x"] + offset["x"],
                "y2": position[2] * scale["y"] + offset["y"],
                "x2": position[3] * scale["x"] + offset["x"],
                "size": 20,
                "text": marker_text,
            }
            temp_dict["marker_properties"] = marker_properties
            markers_dict[
                temp_dict["marker_type"] + str(annotation["UniqueID"])
            ] = temp_dict

    return markers_dict


[docs]def add_annotation_markers(s): """ Add annotation markers from a DM3/DM4 file to a HyperSpy signal. Read annotations from a signal originating from DigitalMicrograph and convert the ones (that we can) into Hyperspy markers for plotting. Adapted from a currently (at the time of writing) open `pull request`_ in HyperSpy. .. _pull request: https://github.com/hyperspy/hyperspy/pull/1491 Parameters ---------- s : :py:class:`hyperspy.signal.BaseSignal` (or subclass) The HyperSpy signal for which a thumbnail should be generated """ # pylint: disable=broad-exception-caught # Parsing markers can potentially lead to errors, so to avoid # this any Exceptions are caught and logged instead of the files # not being loaded at all. try: markers_dict = _get_markers_dict(s, s.original_metadata.as_dictionary()) except Exception as err: logger.warning("Markers could not be loaded from the file due to: %s", err) markers_dict = {} if markers_dict: markers_list = [] for k, v in markers_dict.items(): # convert each marker dictionary item into a Marker object markers_list.append(dict2marker(v, k)) if len(markers_list) > 0: # add the Marker objects (in a list) to the signal s.add_marker(markers_list, permanent=True)
[docs]def sig_to_thumbnail(s, out_path: Path, dpi: int = 92): """ Generate a preview thumbnail from an arbitrary HyperSpy signal. For a 2D signal, the signal from the first navigation position is used (most likely the top- and left-most position. For a 1D signal (*i.e.* a spectrum or spectrum image), the output depends on the number of navigation dimensions: - 0: Image of spectrum - 1: Image of linescan (*a la* DigitalMicrograph) - 2: Image of spectra sampled from navigation space - 2+: As for 2 dimensions Parameters ---------- s : :py:class:`hyperspy.signal.BaseSignal` (or subclass) The HyperSpy signal for which a thumbnail should be generated out_path A path to the desired thumbnail filename. All formats supported by :py:meth:`~matplotlib.figure.Figure.savefig` can be used. dpi : int The "dots per inch" resolution for the outputted figure Returns ------- f : :py:class:`matplotlib.figure.Figure` Handle to a matplotlib Figure Notes ----- This method heavily utilizes HyperSpy's existing plotting functions to figure out how to best display the image """ # close all currently open plots to ensure we don't leave a mess behind # in memory plt.close("all") plt.rcParams["image.cmap"] = "gray" # Processing 1D signals (spectra, spectrum images, etc) if isinstance(s, hs_api.signals.Signal1D): return _plot_1d_signal(s, out_path, dpi) # Signal is an image of some sort, so we'll use hs.plot.plot_images if isinstance(s, hs_api.signals.Signal2D): return _plot_2d_signal(s, out_path, dpi) # Complex image, so plot power spectrum (like an FFT) if isinstance(s, hs_api.signals.ComplexSignal2D): return _plot_complex_signal(s, out_path, dpi) # if we have a different type of signal, just output a graphical # representation of the axis manager return _plot_axes_manager(s, out_path, dpi)
[docs]def text_to_thumbnail( f: Path, out_path: Path, output_size: int = 500, ) -> Union[Figure, bool]: """ Generate a preview thumbnail from a text file. For a text file, the contents will be formatted and written to a 500x500 pixel jpg image of size 5 in by 5 in. If the text file has many newlines, it is probably data and the first 42 characters of each of the first 20 lines of the text file will be written to the image. If the text file has a few (or fewer) newlines, it is probably a manually generated note and the text will be written to a 42 column, 18 row box until the space is exhausted. Parameters ---------- f The path of a text file for which a thumbnail should be generated. out_path A path to the desired thumbnail filename. All formats supported by :py:meth:`~matplotlib.figure.Figure.savefig` can be used. output_size : int The pixel width (and height, since the image is padded to square) of the saved image file. Returns ------- f Handle to a matplotlib Figure, or the value False if a preview could not be generated """ # close all currently open plots to ensure we don't leave a mess behind # in memory plt.close("all") plt.rcParams["image.cmap"] = "gray" # some instruments produce text files with different encodings, so we try a few # of the common ones. Also, escape "$" pattern that matplotlib # will interpret as a math formula and replace "\t" with spaces for neat display textlist = None for enc in ["utf-8", "windows-1250", "windows-1252"]: try: with Path.open(f, encoding=enc) as textfile: textlist = ( textfile.read() .replace("$", r"\$") .replace("\t", " ") .splitlines() ) except UnicodeDecodeError as exc: logger.warning( "no preview generated; could not decode text file with encoding %s: %s", enc, str(exc), ) else: logger.info("opening the file with encoding: %s ", str(enc)) if textlist is None: # textlist being None means that none of the encodings used could open the # text file, so we should just return False to indicate no preview was generated logger.warning( "Could not generate preview of text file with any available encoding", ) return False textfig = plt.figure() # 5 x 5" is a good size size_inches = 5 textfig.set_size_inches(size_inches, size_inches) dpi = output_size / size_inches plt.axis("off") # Number of newlines to distinguish between data-like and note-like text paragraph_check = 15 num_lines_in_image = 19 if len(textlist) <= paragraph_check: wrapped_text = [] for i in textlist: wrapped_text = wrapped_text + textwrap.wrap(i, width=42) lines_printed = 0 while lines_printed <= num_lines_in_image and lines_printed < len(wrapped_text): textfig.text( 0.02, 0.9 - lines_printed / 18, wrapped_text[lines_printed] + "\n", fontsize=12, fontfamily="monospace", ) lines_printed = lines_printed + 1 # textfile is assumed to be hand-typed notes in paragraph format # we will wrap text until we run out of space else: # 17 is the maximum number of lines that will fit in this size image for i in range(17): textfig.text( 0.02, 0.9 - i / 18, textlist[i][0:48] + "\n", fontsize=12, fontfamily="monospace", ) # textfile is assumed to be some form of column data. # we will essentially create an image of the top left corner of the # text file. textfig.tight_layout() textfig.savefig(out_path, dpi=dpi) _pad_to_square(out_path, output_size) return textfig
[docs]def image_to_square_thumbnail(f: Path, out_path: Path, output_size: int) -> bool: """ Generate a preview thumbnail from a non-data image file. Images of common filetypes will be transformed into 500 x 500 pixel images by first scaling the largest dimension to 500 pixels and then padding the resulting image to square. Parameters ---------- f The string of the path of an image file for which a thumbnail should be generated. out_path A path to the desired thumbnail filename. All formats supported by :py:meth:`~PIL.Image.Image.save` can be used. output_size The desired resulting size of the thumbnail image. Returns ------- Whether a preview was generated """ shutil.copy(f, out_path) try: _pad_to_square(out_path, output_size) except UnidentifiedImageError as exc: logger.warning("no preview generated; PIL error text: %s", str(exc)) out_path.unlink() return False return True
def _set_extent_and_save(mpl_axis, s, f, out_path, dpi): _set_title(mpl_axis, s.metadata.General.title) items = [mpl_axis, mpl_axis.title, mpl_axis.xaxis.label, mpl_axis.yaxis.label] for labels in _get_visible_labels(mpl_axis): items += labels extent = _full_extent(mpl_axis, items, pad=0.05).transformed( mpl_axis.figure.dpi_scale_trans.inverted(), ) f.savefig(out_path, bbox_inches=extent, dpi=dpi) _pad_to_square(out_path, 500) def _plot_1d_signal(s, out_path, dpi): # signal is single spectrum if s.axes_manager.navigation_dimension == 0: return _plot_spectrum(s, out_path, dpi) # signal is 1D linescan if s.axes_manager.navigation_dimension == 1: return _plot_linescan(s, out_path, dpi) # otherwise we have spectrum image: return _plot_si(s, out_path, dpi) def _plot_2d_signal(s, out_path, dpi): # signal is single image if s.axes_manager.navigation_dimension == 0: return _plot_single_image(s, out_path, dpi) # we're looking at an image stack if s.axes_manager.navigation_dimension == 1: return _plot_image_stack(s, out_path, dpi) # This is a 4D-STEM type image, so display as tableau return _plot_tableau(s, out_path, dpi) def _plot_spectrum(s, out_path, dpi): # pylint: disable=protected-access s.plot() # get signal plot figure f = s._plot.signal_plot.figure # noqa: SLF001 mpl_axis = f.get_axes()[0] # Change line color to matplotlib default mpl_axis.get_lines()[0].set_color(plt.get_cmap("tab10")(0)) _set_extent_and_save(mpl_axis, s, f, out_path, dpi) return f def _plot_linescan(s, out_path, dpi): # pylint: disable=protected-access s.plot() f = s._plot.navigator_plot.figure # noqa: SLF001 f.get_axes()[1].remove() # remove colorbar scale mpl_axis = f.get_axes()[0] # workaround for above issue to remove pointer for line in list(mpl_axis.lines): line.remove() _set_extent_and_save(mpl_axis, s, f, out_path, dpi) return f def _plot_si(s, out_path, dpi): nav_size = s.axes_manager.navigation_size max_nav_size = 9 # temporarily unfold the signal so we can get spectra from all # over the navigation space easily: with s.unfolded(): idx_to_plot = np.linspace( 0, nav_size - 1, 9 if nav_size >= max_nav_size else nav_size, dtype=int, ) s_to_plot = [s.inav[i] for i in idx_to_plot] f = plt.figure() hs_api.plot.plot_spectra(s_to_plot, style="cascade", padding=0.1, fig=f) mpl_axis = plt.gca() _set_title(mpl_axis, s.metadata.General.title) mpl_axis.set_title( mpl_axis.get_title() + "\n" + r"$\bf{" + r"\ x\ ".join([str(x) for x in s.axes_manager.navigation_shape]) + r"\ Spectrum\ Image}$", ) # Load "watermark" stamp and rescale to be appropriately sized stamp = imread(Path(__file__).parent / "spectrum_image_logo.svg.png") stamp_width = int((mpl_axis.figure.get_size_inches() * f.dpi)[0] / 2.5) scaling = stamp_width / float(stamp.shape[0]) stamp_height = int(float(stamp.shape[1]) * float(scaling)) stamp = resize(stamp, (stamp_width, stamp_height), mode="wrap", anti_aliasing=True) # Create matplotlib annotation with image in center imagebox = OffsetImage(stamp, zoom=1, alpha=0.15) imagebox.image.axes = mpl_axis anchored_offset = AnchoredOffsetbox("center", pad=1, borderpad=0, child=imagebox) anchored_offset.patch.set_alpha(0) mpl_axis.add_artist(anchored_offset) # Pack figure and save f.tight_layout() f.savefig(out_path, dpi=dpi) _pad_to_square(out_path, 500) return f def _plot_single_image(s, out_path, dpi): # check to see if this is a dm3/dm4; if so try to plot with # annotations orig_fname = s.metadata.General.original_filename if ".dm3" in orig_fname or ".dm4" in orig_fname: add_annotation_markers(s) s.plot(colorbar=False) plt.gca().axis("off") else: hs_api.plot.plot_images( [s], axes_decor="off", colorbar=False, scalebar="all", label=None, ) f = plt.gcf() mpl_axis = plt.gca() _set_title(mpl_axis, s.metadata.General.title) f.tight_layout() f.savefig(out_path, dpi=dpi) _pad_to_square(out_path, 500) return f def _plot_image_stack(s, out_path, dpi): plt.figure() plt.imshow( _project_image_stack(s, num=min(5, s.axes_manager.navigation_size), dpi=dpi), ) mpl_axis = plt.gca() mpl_axis.set_position([0, 0, 1, 0.8]) mpl_axis.set_axis_off() _set_title(mpl_axis, s.metadata.General.title) mpl_axis.set_title( mpl_axis.get_title() + "\n" + r"$\bf{" + str(s.axes_manager.navigation_size) + r"-member" + r"\ Image\ Series}$", ) # use _full_extent to determine the bounding box needed to pick # out just the items we're interested in extent = _full_extent(mpl_axis, [mpl_axis, mpl_axis.title], pad=0.1).transformed( mpl_axis.figure.dpi_scale_trans.inverted(), ) mpl_axis.figure.savefig(out_path, bbox_inches=extent, dpi=dpi) _pad_to_square(out_path, 500) return mpl_axis.figure def _plot_tableau(s, out_path, dpi): asp_ratio = s.axes_manager.signal_shape[1] / s.axes_manager.signal_shape[0] f = plt.figure(figsize=(6, 6 * asp_ratio)) if s.axes_manager.navigation_size >= 9: # noqa: PLR2004 square_n = 3 elif s.axes_manager.navigation_size >= 4: # noqa: PLR2004 square_n = 2 else: square_n = 1 num_to_plot = square_n**2 im_list = [None] * num_to_plot desc = r"\ x\ ".join([str(x) for x in s.axes_manager.navigation_shape]) s.unfold_navigation_space() chunk_size = s.axes_manager.navigation_size // num_to_plot for i in range(num_to_plot): if square_n == 1: im_list = [s] else: im_list[i] = s.inav[i * chunk_size : (i + 1) * chunk_size].inav[ chunk_size // 2 ] axlist = hs_api.plot.plot_images( im_list, colorbar=None, axes_decor="off", tight_layout=True, scalebar=[0], per_row=square_n, fig=f, ) # Make sure scalebar is fully on plot: txt = axlist[0].texts[0] left_extent = ( txt.get_window_extent().transformed(axlist[0].transData.inverted()).bounds[0] ) if left_extent < 0: # Move scalebar text over if it overlaps outside of axis txt.set_x(txt.get_position()[0] + left_extent * -1) f.suptitle( textwrap.fill(s.metadata.General.title, 60) + "\n" + r"$\bf{" + desc + r"\ Hyperimage}$", ) f.tight_layout( rect=( 0, 0, 1, f.texts[0] .get_window_extent() .transformed(f.transFigure.inverted()) .bounds[1], ), ) f.savefig(out_path, dpi=dpi) _pad_to_square(out_path, 500) return f def _plot_complex_signal(s, out_path, dpi): # in tests, setting minimum to a percentile around 66% looks good s.amplitude.plot( interpolation="bilinear", norm="log", vmin=np.nanpercentile(s.amplitude.data, 66), colorbar=None, axes_off=True, ) f = plt.gcf() mpl_axis = plt.gca() _set_title(mpl_axis, s.metadata.General.title) extent = _full_extent(mpl_axis, [mpl_axis, mpl_axis.title], pad=0.1).transformed( mpl_axis.figure.dpi_scale_trans.inverted(), ) f.savefig(out_path, dpi=dpi, bbox_inches=extent) _pad_to_square(out_path, 500) return f def _plot_axes_manager(s, out_path, dpi): f, mpl_axis = plt.subplots() mpl_axis.set_position([0, 0, 1, 1]) mpl_axis.set_axis_off() # Remove axes_manager text ax_m = repr(s.axes_manager) ax_m = ax_m.split("\n") ax_m = ax_m[1:] ax_m = "\n".join(ax_m) mpl_axis.text(0.03, 0.9, s.metadata.General.title, fontweight="bold", va="top") mpl_axis.text(0.03, 0.85, "Could not generate preview image", va="top", color="r") mpl_axis.text(0.03, 0.8, "Axes information:", va="top", fontstyle="italic") mpl_axis.text(0.03, 0.75, ax_m, fontfamily="monospace", va="top") extent = _full_extent(mpl_axis, mpl_axis.texts, pad=0.1).transformed( mpl_axis.figure.dpi_scale_trans.inverted(), ) f.savefig(out_path, bbox_inches=extent, dpi=dpi) _pad_to_square(out_path, 500) return f
[docs]def down_sample_image( fname: Path, out_path: Path, output_size: Optional[Tuple[int, int]] = None, factor: Optional[int] = None, ): """ Load an image file from disk, down-sample it to the requested dpi, and save. Sometimes the data doesn't need to be loaded as a HyperSpy signal, and it's better just to down-sample existing image data (such as for .tif files created by the Quanta SEM). Parameters ---------- fname The filepath that will be resized. All formats supported by :py:func:`PIL.Image.open` can be used out_path A path to the desired thumbnail filename. All formats supported by :py:meth:`PIL.Image.Image.save` can be used. output_size A tuple of ints specifying the width and height of the output image. Either this argument or ``factor`` should be provided (not both). factor The multiple of the image size to reduce by (i.e. a value of 2 results in an image that is 50% of each original dimension). Either this argument or ``output_size`` should be provided (not both). """ if output_size is None and factor is None: msg = "One of output_size or factor must be provided" raise ValueError(msg) if output_size is not None and factor is not None: msg = "Only one of output_size or factor should be provided" raise ValueError(msg) image = Image.open(fname) size = image.size if output_size is not None: resized = output_size else: resized = tuple(s // factor for s in size) if "I" in image.mode: image = image.point(lambda i: i * (1.0 / 256)).convert("L") image.thumbnail(resized, resample=_LANCZOS) image.save(out_path) _pad_to_square(out_path, new_width=500) plt.rcParams["image.cmap"] = "gray" f = plt.figure() f.gca().imshow(image) return f