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

1"""HyperSpy-based preview generator for microscopy data files.""" 

2 

3import logging 

4import tempfile 

5import textwrap 

6from pathlib import Path 

7from typing import ClassVar 

8 

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 

19 

20import nexusLIMS.extractors 

21from nexusLIMS.extractors.base import ExtractionContext 

22 

23_logger = logging.getLogger(__name__) 

24 

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 

28 

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""" 

37 

38 

39def _full_extent(axis, items, pad=0.0): 

40 """ 

41 Get the full extent of items in an axis. 

42 

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]) 

49 

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

51 

52 

53def _set_title(axis, title): 

54 """ 

55 Set an axis title with maximum width. 

56 

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

58 of the plot) 

59 

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) 

69 

70 

71def _get_visible_labels(axis): 

72 """ 

73 Get labels that are visible for plot. 

74 

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. 

78 

79 Parameters 

80 ---------- 

81 ax : :py:mod:`matplotlib.axis` 

82 A matplotlib axis instance on which to operate 

83 

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") 

91 

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) 

102 

103 return vis_labels_x, vis_labels_y 

104 

105 

106def _project_image_stack(s, num=5, dpi=92, v_shear=0.3, h_scale=0.3): 

107 """ 

108 Project an image stack. 

109 

110 Create a preview of an image stack by selecting a number of example frames 

111 and projecting them into a pseudo-3D display. 

112 

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) 

129 

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) 

154 

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) 

176 

177 for temp_file in tmps: 

178 temp_file.close() 

179 Path(temp_file.name + ".png").unlink() 

180 

181 return np.hstack(im_data) 

182 

183 

184def _pad_to_square(im_path: Path, new_width: int = 500): 

185 """ 

186 Pad an image to square. 

187 

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. 

192 

193 Method adapted from: 

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

195 

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) 

208 

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) 

215 

216 

217def _get_marker_color(annotation): 

218 """ 

219 Get the color of a DigitalMicrograph annotation. 

220 

221 Parameters 

222 ---------- 

223 annotation : dict 

224 The tag dictionary for a given annotation from a DigitalMicrograph 

225 tag structure 

226 

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" 

255 

256 return color 

257 

258 

259def _get_marker_props(annotation): 

260 """ 

261 Get the properties and type of a DigitalMicrograph annotation. 

262 

263 Parameters 

264 ---------- 

265 annotation : dict 

266 The tag dictionary for a given annotation from a DigitalMicrograph 

267 tag structure 

268 

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 

299 

300 # FillMode: 

301 FILL_NONE = 2 # noqa: N806 

302 

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]) 

332 

333 return marker_properties, marker_type, marker_text 

334 

335 

336def _get_markers_list(s, tags_dict): 

337 """ 

338 Get list of HyperSpy 2.0+ marker objects from a HyperSpy signal. 

339 

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``) 

347 

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} 

356 

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) 

389 

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"] 

396 

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) 

439 

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) 

451 

452 return markers_list 

453 

454 

455def add_annotation_markers(s): 

456 """ 

457 Add annotation markers from a DM3/DM4 file to a HyperSpy signal. 

458 

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. 

462 

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) 

480 

481 

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) 

492 

493 

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 

504 

505 

506def _plot_linescan(s, out_path, dpi): 

507 # pylint: disable=protected-access 

508 s.plot() 

509 

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] 

513 

514 # workaround for above issue to remove pointer 

515 for line in list(mpl_axis.lines): 

516 line.remove() 

517 

518 _set_extent_and_save(mpl_axis, s, f, out_path, dpi) 

519 return f 

520 

521 

522def _plot_si(s, out_path, dpi): 

523 nav_size = s.axes_manager.navigation_size 

524 max_nav_size = 9 

525 

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] 

536 

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() 

540 

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 ) 

549 

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) 

556 

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) 

563 

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 

569 

570 

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 ) 

587 

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 

595 

596 

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 

622 

623 

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 ) 

656 

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) 

665 

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 

687 

688 

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 

707 

708 

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() 

713 

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) 

719 

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") 

724 

725 extent = _full_extent(mpl_axis, mpl_axis.texts, pad=0.1).transformed( 

726 mpl_axis.figure.dpi_scale_trans.inverted(), 

727 ) 

728 

729 f.savefig(out_path, bbox_inches=extent, dpi=dpi) 

730 _pad_to_square(out_path, 500) 

731 return f 

732 

733 

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) 

738 

739 # signal is 1D linescan 

740 if s.axes_manager.navigation_dimension == 1: 

741 return _plot_linescan(s, out_path, dpi) 

742 

743 # otherwise we have spectrum image: 

744 return _plot_si(s, out_path, dpi) 

745 

746 

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) 

751 

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) 

755 

756 # This is a 4D-STEM type image, so display as tableau 

757 return _plot_tableau(s, out_path, dpi) 

758 

759 

760def sig_to_thumbnail(s, out_path: Path, dpi: int = 92): 

761 """ 

762 Generate a preview thumbnail from an arbitrary HyperSpy signal. 

763 

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: 

768 

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 

773 

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 

783 

784 Returns 

785 ------- 

786 f : :py:class:`matplotlib.figure.Figure` 

787 Handle to a matplotlib Figure 

788 

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" 

798 

799 # Set matplotlib backend to avoid GUI issues 

800 mpl.use("Agg") 

801 

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) 

805 

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) 

809 

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) 

813 

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) 

817 

818 

819class HyperSpyPreviewGenerator: 

820 """ 

821 Preview generator for files that can be loaded with HyperSpy. 

822 

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 """ 

827 

828 name = "hyperspy_preview" 

829 priority = 100 

830 supported_extensions: ClassVar = { 

831 "dm3", 

832 "dm4", 

833 "ser", 

834 "emi", 

835 } 

836 

837 def supports(self, context: ExtractionContext) -> bool: 

838 """ 

839 Check if this generator supports the given file. 

840 

841 Parameters 

842 ---------- 

843 context 

844 The extraction context containing file information 

845 

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 

853 

854 def generate(self, context: ExtractionContext, output_path: Path) -> bool: 

855 """ 

856 Generate a thumbnail preview using HyperSpy. 

857 

858 Parameters 

859 ---------- 

860 context 

861 The extraction context containing file information 

862 output_path 

863 Path where the preview image should be saved 

864 

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 

872 

873 _logger.debug("Generating HyperSpy preview for: %s", context.file_path) 

874 

875 # Load the signal 

876 s = load(str(context.file_path), lazy=True) 

877 

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] 

886 

887 # Generate the thumbnail using the local function 

888 sig_to_thumbnail(s, output_path, dpi=92) 

889 

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