Coverage for nexusLIMS/extractors/plugins/preview_generators/hyperspy_preview.py: 100%
359 statements
« prev ^ index » next coverage.py v7.11.3, created at 2026-03-24 05:23 +0000
« prev ^ index » next coverage.py v7.11.3, created at 2026-03-24 05:23 +0000
1"""HyperSpy-based preview generator for microscopy data files."""
3import logging
4import tempfile
5import textwrap
6from pathlib import Path
7from typing import ClassVar
9import hyperspy.api as hs_api
10import matplotlib as mpl
11import matplotlib.pyplot as plt
12import numpy as np
13from matplotlib.offsetbox import AnchoredOffsetbox, OffsetImage
14from matplotlib.transforms import Bbox
15from PIL import Image
16from skimage import transform
17from skimage.io import imread
18from skimage.transform import resize # pylint: disable=no-name-in-module
20import nexusLIMS.extractors
21from nexusLIMS.extractors.base import ExtractionContext
23_logger = logging.getLogger(__name__)
25# Use modern HyperSpy 2.0+ API only
26# Marker functionality has changed significantly in HyperSpy 2.0+
27# The old dict2marker approach is no longer supported
29_LANCZOS = Image.Resampling.LANCZOS
30_POINT_SIZE = 5
31SPECTRUM_IMAGE_LOGO = (
32 Path(nexusLIMS.extractors.__file__).parent
33 / "assets"
34 / "spectrum_image_logo.svg.png"
35)
36"""Logo background to use for a spectrum image preview"""
39def _full_extent(axis, items, pad=0.0):
40 """
41 Get the full extent of items in an axis.
43 Adapted from https://stackoverflow.com/a/26432947/1435788.
44 """
45 # For text objects, we need to draw the figure first, otherwise the extents
46 # are undefined.
47 axis.figure.canvas.draw()
48 bbox = Bbox.union([item.get_window_extent() for item in items])
50 return bbox.expanded(1.0 + pad, 1.0 + pad)
53def _set_title(axis, title):
54 """
55 Set an axis title with maximum width.
57 Makes sure it is no wider than 60 characters (so it doesn't run over the edges
58 of the plot)
60 Parameters
61 ----------
62 ax : :py:mod:`matplotlib.axis`
63 A matplotlib axis instance on which to operate
64 title : str
65 The desired axis title
66 """
67 new_title = textwrap.fill(title, 60)
68 axis.set_title(new_title)
71def _get_visible_labels(axis):
72 """
73 Get labels that are visible for plot.
75 Helper method to return only the tick labels that are visible given the
76 current extent of the axes. Useful when calculating the extent of the figure
77 to save so extra white space from invisible labels is not included.
79 Parameters
80 ----------
81 ax : :py:mod:`matplotlib.axis`
82 A matplotlib axis instance on which to operate
84 Returns
85 -------
86 vis_labels_x, vis_labels_y : tuple of lists
87 lists of only the label objects that are visible on the current axis
88 """
89 vis_labels_x = mpl.cbook.silent_list("Text xticklabel")
90 vis_labels_y = mpl.cbook.silent_list("Text yticklabel")
92 for label in axis.get_xticklabels():
93 label_pos = label.get_position()[0]
94 x_limits = axis.get_xlim()
95 if x_limits[0] < label_pos < x_limits[1]:
96 vis_labels_x.append(label)
97 for label in axis.get_yticklabels():
98 label_pos = label.get_position()[1]
99 y_limits = axis.get_ylim()
100 if y_limits[0] < label_pos < y_limits[1]:
101 vis_labels_y.append(label)
103 return vis_labels_x, vis_labels_y
106def _project_image_stack(s, num=5, dpi=92, v_shear=0.3, h_scale=0.3):
107 """
108 Project an image stack.
110 Create a preview of an image stack by selecting a number of example frames
111 and projecting them into a pseudo-3D display.
113 Parameters
114 ----------
115 s : :py:class:`hyperspy.signal.BaseSignal` (or subclass)
116 The HyperSpy signal for which an image stack preview should be
117 generated. Should have a signal dimension of 2 and a navigation
118 dimension of 1.
119 num : int
120 The number of frames in the image stack to use to make the preview
121 dpi : int
122 The "dots per inch" of the individual frames within the preview
123 v_shear : float
124 The factor by which to vertically shear (0.5 means shear the top border
125 down by half of the original image's height)
126 h_scale : float
127 The factor by which to scale in the horizontal direction (0.3 means
128 each projected frame will be 30% the width of the original image)
130 Returns
131 -------
132 output : :py:class:`numpy.ndarray`
133 The `num` frames loaded into a single NumPy array for plotting
134 """
135 tmps = []
136 for i in np.linspace(0, s.axes_manager.navigation_size - 1, num=num, dtype=int):
137 hs_api.plot.plot_images(
138 [s.inav[i].as_signal2D((0, 1))],
139 axes_decor="off",
140 colorbar=False,
141 scalebar="all",
142 label=None,
143 )
144 tmp = tempfile.NamedTemporaryFile() # noqa: SIM115
145 axis = plt.gca()
146 axis.set_position([0, 0, 1, 1])
147 axis.set_axis_on()
148 for axis_side in ["top", "bottom", "left", "right"]:
149 axis.spines[axis_side].set_linewidth(5)
150 axis.figure.canvas.draw()
151 axis.figure.savefig(tmp.name + ".png", dpi=dpi)
152 tmps.append(tmp)
153 plt.close(axis.figure)
155 im_data = []
156 for tmp in tmps:
157 img = plt.imread(tmp.name + ".png")
158 img_trans = transform.warp(
159 image=img,
160 inverse_map=np.dot(
161 np.array([[1, 0, 0], [-1 * v_shear, 1, 0], [0, 0, 1]]), # shear
162 np.linalg.inv(
163 np.array([[h_scale, 0, 0], [0, 1, 0], [0, 0, 1]]),
164 ), # scale
165 ),
166 order=1,
167 preserve_range=True,
168 mode="constant",
169 cval=np.nan,
170 output_shape=(
171 int(img.shape[1] * (1 + v_shear)),
172 int(img.shape[0] * h_scale),
173 ),
174 )
175 im_data.append(img_trans)
177 for temp_file in tmps:
178 temp_file.close()
179 Path(temp_file.name + ".png").unlink()
181 return np.hstack(im_data)
184def _pad_to_square(im_path: Path, new_width: int = 500):
185 """
186 Pad an image to square.
188 Helper method to pad an image saved on disk to a square with size
189 ``width x width``. This ensures consistent display on the front-end web
190 page. Increasing the size of a dimension is done by padding with empty
191 space. The original image is overwritten.
193 Method adapted from:
194 https://jdhao.github.io/2017/11/06/resize-image-to-square-with-padding/
196 Parameters
197 ----------
198 im_path
199 The path to the image that should be resized/padded
200 new_width
201 Desired output width/height of the image (in pixels)
202 """
203 image = Image.open(im_path)
204 old_size = image.size # old_size[0] is in (width, height) format
205 ratio = float(new_width) / max(old_size)
206 new_size = tuple(int(x * ratio) for x in old_size)
207 image = image.resize(new_size, _LANCZOS)
209 new_im = Image.new("RGBA", (new_width, new_width))
210 new_im.paste(
211 image,
212 ((new_width - new_size[0]) // 2, (new_width - new_size[1]) // 2),
213 )
214 new_im.save(im_path)
217def _get_marker_color(annotation):
218 """
219 Get the color of a DigitalMicrograph annotation.
221 Parameters
222 ----------
223 annotation : dict
224 The tag dictionary for a given annotation from a DigitalMicrograph
225 tag structure
227 Returns
228 -------
229 color : str or tuple
230 Either an RGB tuple, or string containing a color name
231 """
232 if ("ForegroundColor" in annotation) or ("Color" in annotation):
233 # There seems to be 3 different colors in annotations in
234 # dm3-files: Color, ForegroundColor and BackgroundColor.
235 # ForegroundColor and BackgroundColor seems to be present
236 # for all annotations. Color is present in some of them.
237 # If Color is present, it seems to override the others.
238 # Currently, BackgroundColor is not utilized, due to
239 # HyperSpy markers only supporting a single color.
240 if "Color" in annotation:
241 color_raw = annotation["Color"]
242 else:
243 color_raw = annotation["ForegroundColor"]
244 # Colors in DM are saved as negative values
245 # Some values are also in 16-bit
246 color = []
247 for raw_value in color_raw:
248 raw_value_ = abs(raw_value)
249 if raw_value_ > 1:
250 raw_value_ /= 2**16
251 color.append(raw_value_)
252 color = tuple(color)
253 else:
254 color = "red"
256 return color
259def _get_marker_props(annotation):
260 """
261 Get the properties and type of a DigitalMicrograph annotation.
263 Parameters
264 ----------
265 annotation : dict
266 The tag dictionary for a given annotation from a DigitalMicrograph
267 tag structure
269 Returns
270 -------
271 marker_properties : dict
272 A dictionary containing various properties for this
273 annotation/marker, such as line width, style, etc.
274 marker_type : str or None
275 The type of marker (e.g., "LineSegment", "Rectangle", "Text", "Point")
276 marker_text : None or str
277 If present, the text of a textual annotation
278 """
279 marker_properties = {}
280 marker_type = None
281 marker_text = None
282 log_msg_map = {
283 3: "Arrow marker not loaded: not implemented",
284 4: "Double arrow marker not loaded: not implemented",
285 6: "Ellipse marker not loaded: not implemented",
286 8: "Mask spot marker not loaded: not implemented",
287 9: "Mask array marker not loaded: not implemented",
288 15: "Mask band pass marker not loaded: not implemented",
289 19: "Mask wedge marker not loaded: not implemented",
290 29: "ROI curve marker not loaded: not implemented",
291 31: "Scalebar marker not loaded: not implemented",
292 }
293 line_segment_type = 2
294 rectangle_type = 5
295 text_type = 13
296 roi_rectangle_type = 23
297 roi_line_type = 25
298 point_type = 27
300 # FillMode:
301 FILL_NONE = 2 # noqa: N806
303 if "AnnotationType" in annotation:
304 annotation_type = annotation["AnnotationType"]
305 color = _get_marker_color(annotation)
306 marker_properties["color"] = color
307 if annotation_type == line_segment_type:
308 marker_type = "LineSegment"
309 marker_properties["linewidth"] = 2
310 elif annotation_type in (rectangle_type, roi_rectangle_type):
311 marker_type = "Rectangle"
312 marker_properties["linewidth"] = 2
313 # ROI rectangles get dashed lines
314 if annotation_type == roi_rectangle_type:
315 marker_properties["linestyle"] = "--"
316 if annotation["FillMode"] == FILL_NONE:
317 marker_properties["facecolor"] = "none"
318 marker_properties["edgecolor"] = marker_properties.pop("color")
319 marker_properties["labelcolor"] = marker_properties["edgecolor"]
320 elif annotation_type == text_type:
321 marker_type = "Text"
322 marker_text = annotation["Text"]
323 elif annotation_type == roi_line_type: # roiline
324 marker_type = "LineSegment"
325 marker_properties["linestyle"] = "--"
326 marker_properties["linewidth"] = 2
327 elif annotation_type == point_type:
328 marker_type = "Point"
329 marker_properties["size"] = _POINT_SIZE
330 elif annotation_type in log_msg_map:
331 _logger.debug(log_msg_map[annotation_type])
333 return marker_properties, marker_type, marker_text
336def _get_markers_list(s, tags_dict):
337 """
338 Get list of HyperSpy 2.0+ marker objects from a HyperSpy signal.
340 Parameters
341 ----------
342 s : :py:class:`hyperspy.signal.BaseSignal`
343 The HyperSpy signal from which annotations should be read
344 tags_dict : dict
345 The dictionary of DigitalMicrograph tags (saved as
346 ``s.original_metadata``)
348 Returns
349 -------
350 markers_list : list
351 List of HyperSpy 2.0+ marker objects that correspond to the
352 annotations found in `s`
353 """
354 scale = {"x": s.axes_manager["x"].scale, "y": s.axes_manager["y"].scale}
355 offset = {"x": s.axes_manager["x"].offset, "y": s.axes_manager["y"].offset}
357 markers_list = []
358 annotations_dict = tags_dict["DocumentObjectList"]["TagGroup0"][
359 "AnnotationGroupList"
360 ]
361 for annotation in annotations_dict.values():
362 position = None # Initialize position to avoid pylint warning
363 if "Rectangle" in annotation:
364 position = annotation["Rectangle"]
365 marker_properties, marker_type, marker_text = _get_marker_props(annotation)
366 if marker_type:
367 # Add label text marker if present
368 if "Label" in annotation and annotation["Label"] != []:
369 marker_label = annotation["Label"]
370 # Add small vertical offset to position label below the marker
371 label_offset_y = scale["y"] * -4 # 4 pixels in data coordinates
372 y1 = (
373 (position[0] * scale["y"] + offset["y"] + label_offset_y)
374 if position
375 else 0
376 )
377 x1 = position[1] * scale["x"] + offset["x"] if position else 0
378 try:
379 label_marker = hs_api.plot.markers.Texts(
380 offsets=[(x1, y1)],
381 texts=[marker_label],
382 color=marker_properties.pop("labelcolor", "none"),
383 verticalalignment="bottom",
384 horizontalalignment="left",
385 )
386 markers_list.append(label_marker)
387 except Exception as err:
388 _logger.debug("Failed to create label marker: %s", err)
390 # Create the main marker based on type
391 if position:
392 y1 = position[0] * scale["y"] + offset["y"]
393 x1 = position[1] * scale["x"] + offset["x"]
394 y2 = position[2] * scale["y"] + offset["y"]
395 x2 = position[3] * scale["x"] + offset["x"]
397 try:
398 if marker_type == "Text":
399 marker = hs_api.plot.markers.Texts(
400 offsets=[(x1, y1)],
401 texts=[marker_text or ""],
402 verticalalignment="bottom",
403 horizontalalignment="left",
404 **marker_properties,
405 )
406 markers_list.append(marker)
407 elif marker_type == "Point":
408 marker = hs_api.plot.markers.Points(
409 offsets=[(x1, y1)],
410 sizes=[marker_properties.pop("size", _POINT_SIZE)],
411 **marker_properties,
412 )
413 markers_list.append(marker)
414 elif marker_type == "LineSegment":
415 marker = hs_api.plot.markers.Lines(
416 segments=[[(x1, y1), (x2, y2)]],
417 **marker_properties,
418 )
419 markers_list.append(marker)
420 elif marker_type == "Rectangle":
421 width = abs(x2 - x1)
422 height = abs(y2 - y1)
423 x = min(x1, x2) + width / 2
424 y = min(y1, y2) + height / 2
425 # Work with a copy to avoid mutating original marker_properties
426 rect_props = marker_properties.copy()
427 edgecolors = (
428 [rect_props.pop("edgecolor")]
429 if "edgecolor" in rect_props
430 else None
431 )
432 facecolors = (
433 [rect_props.pop("facecolor")]
434 if "facecolor" in rect_props
435 else None
436 )
437 # Remove labelcolor if present (not used for rectangles)
438 rect_props.pop("labelcolor", None)
440 marker = hs_api.plot.markers.Rectangles(
441 offsets=[(x, y)],
442 widths=[width],
443 heights=[height],
444 edgecolors=edgecolors,
445 facecolors=facecolors,
446 **rect_props,
447 )
448 markers_list.append(marker)
449 except Exception as err:
450 _logger.debug("Failed to create %s marker: %s", marker_type, err)
452 return markers_list
455def add_annotation_markers(s):
456 """
457 Add annotation markers from a DM3/DM4 file to a HyperSpy signal.
459 Read annotations from a signal originating from DigitalMicrograph and
460 convert the ones (that we can) into HyperSpy 2.0+ markers for plotting.
461 Uses the modern hs.plot.markers API instead of the deprecated dict2marker.
463 Parameters
464 ----------
465 s : :py:class:`hyperspy.signal.BaseSignal` (or subclass)
466 The HyperSpy signal for which a thumbnail should be generated
467 """
468 # pylint: disable=broad-exception-caught
469 # Parsing markers can potentially lead to errors, so to avoid
470 # this any Exceptions are caught and logged instead of the files
471 # not being loaded at all.
472 try:
473 markers_list = _get_markers_list(s, s.original_metadata.as_dictionary())
474 except Exception as err:
475 _logger.warning("Markers could not be loaded from the file due to: %s", err)
476 markers_list = []
477 if markers_list:
478 # Add the HyperSpy 2.0+ Marker objects (in a list) to the signal
479 s.add_marker(markers_list, permanent=True)
482def _set_extent_and_save(mpl_axis, s, f, out_path, dpi):
483 _set_title(mpl_axis, s.metadata.General.title)
484 items = [mpl_axis, mpl_axis.title, mpl_axis.xaxis.label, mpl_axis.yaxis.label]
485 for labels in _get_visible_labels(mpl_axis):
486 items += labels
487 extent = _full_extent(mpl_axis, items, pad=0.05).transformed(
488 mpl_axis.figure.dpi_scale_trans.inverted(),
489 )
490 f.savefig(out_path, bbox_inches=extent, dpi=dpi)
491 _pad_to_square(out_path, 500)
494def _plot_spectrum(s, out_path, dpi):
495 # pylint: disable=protected-access
496 s.plot()
497 # get signal plot figure
498 f = s._plot.signal_plot.figure # noqa: SLF001
499 mpl_axis = f.get_axes()[0]
500 # Change line color to matplotlib default
501 mpl_axis.get_lines()[0].set_color(plt.get_cmap("tab10")(0))
502 _set_extent_and_save(mpl_axis, s, f, out_path, dpi)
503 return f
506def _plot_linescan(s, out_path, dpi):
507 # pylint: disable=protected-access
508 s.plot()
510 f = s._plot.navigator_plot.figure # noqa: SLF001
511 f.get_axes()[1].remove() # remove colorbar scale
512 mpl_axis = f.get_axes()[0]
514 # workaround for above issue to remove pointer
515 for line in list(mpl_axis.lines):
516 line.remove()
518 _set_extent_and_save(mpl_axis, s, f, out_path, dpi)
519 return f
522def _plot_si(s, out_path, dpi):
523 nav_size = s.axes_manager.navigation_size
524 max_nav_size = 9
526 # temporarily unfold the signal so we can get spectra from all
527 # over the navigation space easily:
528 with s.unfolded():
529 idx_to_plot = np.linspace(
530 0,
531 nav_size - 1,
532 9 if nav_size >= max_nav_size else nav_size,
533 dtype=int,
534 )
535 s_to_plot = [s.inav[i] for i in idx_to_plot]
537 f = plt.figure()
538 hs_api.plot.plot_spectra(s_to_plot, style="cascade", padding=0.1, fig=f)
539 mpl_axis = plt.gca()
541 _set_title(mpl_axis, s.metadata.General.title)
542 mpl_axis.set_title(
543 mpl_axis.get_title()
544 + "\n"
545 + r"$\bf{"
546 + r"\ x\ ".join([str(x) for x in s.axes_manager.navigation_shape])
547 + r"\ Spectrum\ Image}$",
548 )
550 # Load "watermark" stamp and rescale to be appropriately sized
551 stamp = imread(SPECTRUM_IMAGE_LOGO)
552 stamp_width = int((mpl_axis.figure.get_size_inches() * f.dpi)[0] / 2.5)
553 scaling = stamp_width / float(stamp.shape[0])
554 stamp_height = int(float(stamp.shape[1]) * float(scaling))
555 stamp = resize(stamp, (stamp_width, stamp_height), mode="wrap", anti_aliasing=True)
557 # Create matplotlib annotation with image in center
558 imagebox = OffsetImage(stamp, zoom=1, alpha=0.15)
559 imagebox.image.axes = mpl_axis
560 anchored_offset = AnchoredOffsetbox("center", pad=1, borderpad=0, child=imagebox)
561 anchored_offset.patch.set_alpha(0)
562 mpl_axis.add_artist(anchored_offset)
564 # Pack figure and save
565 f.tight_layout()
566 f.savefig(out_path, dpi=dpi)
567 _pad_to_square(out_path, 500)
568 return f
571def _plot_single_image(s, out_path, dpi):
572 # check to see if this is a dm3/dm4; if so try to plot with
573 # annotations
574 orig_fname = s.metadata.General.original_filename
575 if ".dm3" in orig_fname or ".dm4" in orig_fname:
576 add_annotation_markers(s)
577 s.plot(colorbar=False)
578 plt.gca().axis("off")
579 else:
580 hs_api.plot.plot_images(
581 [s],
582 axes_decor="off",
583 colorbar=False,
584 scalebar="all",
585 label=None,
586 )
588 f = plt.gcf()
589 mpl_axis = plt.gca()
590 _set_title(mpl_axis, s.metadata.General.title)
591 f.tight_layout()
592 f.savefig(out_path, dpi=dpi)
593 _pad_to_square(out_path, 500)
594 return f
597def _plot_image_stack(s, out_path, dpi):
598 plt.figure()
599 plt.imshow(
600 _project_image_stack(s, num=min(5, s.axes_manager.navigation_size), dpi=dpi),
601 )
602 mpl_axis = plt.gca()
603 mpl_axis.set_position([0, 0, 1, 0.8])
604 mpl_axis.set_axis_off()
605 _set_title(mpl_axis, s.metadata.General.title)
606 mpl_axis.set_title(
607 mpl_axis.get_title()
608 + "\n"
609 + r"$\bf{"
610 + str(s.axes_manager.navigation_size)
611 + r"-member"
612 + r"\ Image\ Series}$",
613 )
614 # use _full_extent to determine the bounding box needed to pick
615 # out just the items we're interested in
616 extent = _full_extent(mpl_axis, [mpl_axis, mpl_axis.title], pad=0.1).transformed(
617 mpl_axis.figure.dpi_scale_trans.inverted(),
618 )
619 mpl_axis.figure.savefig(out_path, bbox_inches=extent, dpi=dpi)
620 _pad_to_square(out_path, 500)
621 return mpl_axis.figure
624def _plot_tableau(s, out_path, dpi):
625 tableau_3x3_limit = 9
626 tableau_2x2_limit = 4
627 asp_ratio = s.axes_manager.signal_shape[1] / s.axes_manager.signal_shape[0]
628 f = plt.figure(figsize=(6, 6 * asp_ratio))
629 if s.axes_manager.navigation_size >= tableau_3x3_limit:
630 square_n = 3
631 elif s.axes_manager.navigation_size >= tableau_2x2_limit:
632 square_n = 2
633 else:
634 square_n = 1
635 num_to_plot = square_n**2
636 im_list = [None] * num_to_plot
637 desc = r"\ x\ ".join([str(x) for x in s.axes_manager.navigation_shape])
638 s.unfold_navigation_space()
639 chunk_size = s.axes_manager.navigation_size // num_to_plot
640 for i in range(num_to_plot):
641 if square_n == 1:
642 im_list = [s]
643 else:
644 im_list[i] = s.inav[i * chunk_size : (i + 1) * chunk_size].inav[
645 chunk_size // 2
646 ]
647 axlist = hs_api.plot.plot_images(
648 im_list,
649 colorbar=None,
650 axes_decor="off",
651 tight_layout=True,
652 scalebar=[0],
653 per_row=square_n,
654 fig=f,
655 )
657 # Make sure scalebar is fully on plot:
658 txt = axlist[0].texts[0]
659 left_extent = (
660 txt.get_window_extent().transformed(axlist[0].transData.inverted()).bounds[0]
661 )
662 if left_extent < 0: # pragma: no cover
663 # Move scalebar text over if it overlaps outside of axis
664 txt.set_x(txt.get_position()[0] + left_extent * -1)
666 f.suptitle(
667 textwrap.fill(s.metadata.General.title, 60)
668 + "\n"
669 + r"$\bf{"
670 + desc
671 + r"\ Hyperimage}$",
672 )
673 f.tight_layout(
674 rect=(
675 0,
676 0,
677 1,
678 f.texts[0]
679 .get_window_extent()
680 .transformed(f.transFigure.inverted())
681 .bounds[1],
682 ),
683 )
684 f.savefig(out_path, dpi=dpi)
685 _pad_to_square(out_path, 500)
686 return f
689def _plot_complex_signal(s, out_path, dpi):
690 # in tests, setting minimum to a percentile around 66% looks good
691 s.amplitude.plot(
692 interpolation="bilinear",
693 norm="log",
694 vmin=float(np.nanpercentile(s.amplitude.data, 66)),
695 colorbar=None,
696 axes_off=True,
697 )
698 f = plt.gcf()
699 mpl_axis = plt.gca()
700 _set_title(mpl_axis, s.metadata.General.title)
701 extent = _full_extent(mpl_axis, [mpl_axis, mpl_axis.title], pad=0.1).transformed(
702 mpl_axis.figure.dpi_scale_trans.inverted(),
703 )
704 f.savefig(out_path, dpi=dpi, bbox_inches=extent)
705 _pad_to_square(out_path, 500)
706 return f
709def _plot_axes_manager(s, out_path, dpi):
710 f, mpl_axis = plt.subplots()
711 mpl_axis.set_position([0, 0, 1, 1])
712 mpl_axis.set_axis_off()
714 # Remove axes_manager text
715 ax_m = repr(s.axes_manager)
716 ax_m = ax_m.split("\n")
717 ax_m = ax_m[1:]
718 ax_m = "\n".join(ax_m)
720 mpl_axis.text(0.03, 0.9, s.metadata.General.title, fontweight="bold", va="top")
721 mpl_axis.text(0.03, 0.85, "Could not generate preview image", va="top", color="r")
722 mpl_axis.text(0.03, 0.8, "Axes information:", va="top", fontstyle="italic")
723 mpl_axis.text(0.03, 0.75, ax_m, fontfamily="monospace", va="top")
725 extent = _full_extent(mpl_axis, mpl_axis.texts, pad=0.1).transformed(
726 mpl_axis.figure.dpi_scale_trans.inverted(),
727 )
729 f.savefig(out_path, bbox_inches=extent, dpi=dpi)
730 _pad_to_square(out_path, 500)
731 return f
734def _plot_1d_signal(s, out_path, dpi):
735 # signal is single spectrum
736 if s.axes_manager.navigation_dimension == 0:
737 return _plot_spectrum(s, out_path, dpi)
739 # signal is 1D linescan
740 if s.axes_manager.navigation_dimension == 1:
741 return _plot_linescan(s, out_path, dpi)
743 # otherwise we have spectrum image:
744 return _plot_si(s, out_path, dpi)
747def _plot_2d_signal(s, out_path, dpi):
748 # signal is single image
749 if s.axes_manager.navigation_dimension == 0:
750 return _plot_single_image(s, out_path, dpi)
752 # we're looking at an image stack
753 if s.axes_manager.navigation_dimension == 1:
754 return _plot_image_stack(s, out_path, dpi)
756 # This is a 4D-STEM type image, so display as tableau
757 return _plot_tableau(s, out_path, dpi)
760def sig_to_thumbnail(s, out_path: Path, dpi: int = 92):
761 """
762 Generate a preview thumbnail from an arbitrary HyperSpy signal.
764 For a 2D signal, the signal from the first navigation position is used (most
765 likely the top- and left-most position. For a 1D signal (*i.e.* a
766 spectrum or spectrum image), the output depends on the
767 number of navigation dimensions:
769 - 0: Image of spectrum
770 - 1: Image of linescan (*a la* DigitalMicrograph)
771 - 2: Image of spectra sampled from navigation space
772 - 2+: As for 2 dimensions
774 Parameters
775 ----------
776 s : :py:class:`hyperspy.signal.BaseSignal` (or subclass)
777 The HyperSpy signal for which a thumbnail should be generated
778 out_path
779 A path to the desired thumbnail filename. All formats supported by
780 :py:meth:`~matplotlib.figure.Figure.savefig` can be used.
781 dpi : int
782 The "dots per inch" resolution for the outputted figure
784 Returns
785 -------
786 f : :py:class:`matplotlib.figure.Figure`
787 Handle to a matplotlib Figure
789 Notes
790 -----
791 This method heavily utilizes HyperSpy's existing plotting functions to
792 figure out how to best display the image
793 """
794 # close all currently open plots to ensure we don't leave a mess behind
795 # in memory
796 plt.close("all")
797 plt.rcParams["image.cmap"] = "gray"
799 # Set matplotlib backend to avoid GUI issues
800 mpl.use("Agg")
802 # Processing 1D signals (spectra, spectrum images, etc)
803 if isinstance(s, hs_api.signals.Signal1D):
804 return _plot_1d_signal(s, out_path, dpi)
806 # Signal is an image of some sort, so we'll use hs.plot.plot_images
807 if isinstance(s, hs_api.signals.Signal2D):
808 return _plot_2d_signal(s, out_path, dpi)
810 # Complex image, so plot power spectrum (like an FFT)
811 if isinstance(s, hs_api.signals.ComplexSignal2D):
812 return _plot_complex_signal(s, out_path, dpi)
814 # if we have a different type of signal, just output a graphical
815 # representation of the axis manager
816 return _plot_axes_manager(s, out_path, dpi)
819class HyperSpyPreviewGenerator:
820 """
821 Preview generator for files that can be loaded with HyperSpy.
823 This generator handles preview generation for scientific data files
824 that HyperSpy can load, including dm3, dm4, ser, and other formats.
825 It uses the sig_to_thumbnail function to create previews.
826 """
828 name = "hyperspy_preview"
829 priority = 100
830 supported_extensions: ClassVar = {
831 "dm3",
832 "dm4",
833 "ser",
834 "emi",
835 }
837 def supports(self, context: ExtractionContext) -> bool:
838 """
839 Check if this generator supports the given file.
841 Parameters
842 ----------
843 context
844 The extraction context containing file information
846 Returns
847 -------
848 bool
849 True if file extension is supported by HyperSpy
850 """
851 extension = context.file_path.suffix.lower().lstrip(".")
852 return extension in self.supported_extensions
854 def generate(self, context: ExtractionContext, output_path: Path) -> bool:
855 """
856 Generate a thumbnail preview using HyperSpy.
858 Parameters
859 ----------
860 context
861 The extraction context containing file information
862 output_path
863 Path where the preview image should be saved
865 Returns
866 -------
867 bool
868 True if preview was successfully generated, False otherwise
869 """
870 try:
871 from hyperspy.io import load # noqa: PLC0415
873 _logger.debug("Generating HyperSpy preview for: %s", context.file_path)
875 # Load the signal
876 s = load(str(context.file_path), lazy=True)
878 # Handle multi-signal files
879 if isinstance(s, list):
880 if context.signal_index is not None:
881 # Use specified signal index
882 s = s[context.signal_index]
883 else:
884 # Legacy: use first signal only
885 s = s[0]
887 # Generate the thumbnail using the local function
888 sig_to_thumbnail(s, output_path, dpi=92)
890 return output_path.exists()
891 except Exception as e:
892 _logger.warning(
893 "Failed to generate HyperSpy preview for %s: %s",
894 context.file_path,
895 e,
896 )
897 return False