Coverage for nexusLIMS/extractors/plugins/preview_generators/image_preview.py: 100%

62 statements  

« prev     ^ index     » next       coverage.py v7.11.3, created at 2026-03-24 05:23 +0000

1"""Image file preview generator.""" 

2 

3import logging 

4import shutil 

5from pathlib import Path 

6from typing import ClassVar, Tuple 

7 

8import matplotlib.pyplot as plt 

9from PIL import Image, UnidentifiedImageError 

10 

11from nexusLIMS.extractors.base import ExtractionContext 

12 

13_logger = logging.getLogger(__name__) 

14 

15_LANCZOS = Image.Resampling.LANCZOS 

16 

17 

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

19 """ 

20 Pad an image to square. 

21 

22 Helper method to pad an image saved on disk to a square with size 

23 ``width x width``. This ensures consistent display on the front-end web 

24 page. Increasing the size of a dimension is done by padding with empty 

25 space. The original image is overwritten. 

26 

27 Method adapted from: 

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

29 

30 Parameters 

31 ---------- 

32 im_path 

33 The path to the image that should be resized/padded 

34 new_width 

35 Desired output width/height of the image (in pixels) 

36 """ 

37 image = Image.open(im_path) 

38 old_size = image.size # old_size[0] is in (width, height) format 

39 ratio = float(new_width) / max(old_size) 

40 new_size = tuple(int(x * ratio) for x in old_size) 

41 image = image.resize(new_size, _LANCZOS) 

42 

43 new_im = Image.new("RGBA", (new_width, new_width)) 

44 new_im.paste( 

45 image, 

46 ((new_width - new_size[0]) // 2, (new_width - new_size[1]) // 2), 

47 ) 

48 new_im.save(im_path) 

49 

50 

51def image_to_square_thumbnail(f: Path, out_path: Path, output_size: int) -> bool: 

52 """ 

53 Generate a preview thumbnail from a non-data image file. 

54 

55 Images of common filetypes will be transformed into 500 x 500 pixel images 

56 by first scaling the largest dimension to 500 pixels and then padding the 

57 resulting image to square. 

58 

59 Parameters 

60 ---------- 

61 f 

62 The string of the path of an image file for which a thumbnail should be 

63 generated. 

64 out_path 

65 A path to the desired thumbnail filename. All formats supported by 

66 :py:meth:`~PIL.Image.Image.save` can be used. 

67 output_size 

68 The desired resulting size of the thumbnail image. 

69 

70 Returns 

71 ------- 

72 bool 

73 Whether a preview was generated 

74 """ 

75 shutil.copy(f, out_path) 

76 try: 

77 _pad_to_square(out_path, output_size) 

78 except UnidentifiedImageError as exc: 

79 _logger.warning("no preview generated; PIL error text: %s", str(exc)) 

80 out_path.unlink() 

81 return False 

82 

83 return True 

84 

85 

86def down_sample_image( 

87 fname: Path, 

88 out_path: Path, 

89 output_size: Tuple[int, int] | None = None, 

90 factor: int | None = None, 

91): 

92 """ 

93 Load an image file from disk, down-sample it to the requested dpi, and save. 

94 

95 Sometimes the data doesn't need to be loaded as a HyperSpy signal, 

96 and it's better just to down-sample existing image data (such as for .tif 

97 files created by the Quanta SEM). 

98 

99 Parameters 

100 ---------- 

101 fname 

102 The filepath that will be resized. All formats supported by 

103 :py:func:`PIL.Image.open` can be used 

104 out_path 

105 A path to the desired thumbnail filename. All formats supported by 

106 :py:meth:`PIL.Image.Image.save` can be used. 

107 output_size 

108 A tuple of ints specifying the width and height of the output image. 

109 Either this argument or ``factor`` should be provided (not both). 

110 factor 

111 The multiple of the image size to reduce by (i.e. a value of 2 

112 results in an image that is 50% of each original dimension). Either 

113 this argument or ``output_size`` should be provided (not both). 

114 """ 

115 if output_size is None and factor is None: 

116 msg = "One of output_size or factor must be provided" 

117 raise ValueError(msg) 

118 if output_size is not None and factor is not None: 

119 msg = "Only one of output_size or factor should be provided" 

120 raise ValueError(msg) 

121 

122 image = Image.open(fname) 

123 size = image.size 

124 

125 if output_size is not None: 

126 resized = output_size 

127 else: 

128 resized = tuple(s // factor for s in size) 

129 

130 if "I" in image.mode: 

131 image = image.point(lambda i: i * (1.0 / 256)).convert("L") 

132 

133 image.thumbnail(resized, resample=_LANCZOS) 

134 image.save(out_path) 

135 _pad_to_square(out_path, new_width=500) 

136 

137 plt.rcParams["image.cmap"] = "gray" 

138 f = plt.figure() 

139 f.gca().imshow(image) 

140 

141 return f 

142 

143 

144class ImagePreviewGenerator: 

145 """ 

146 Preview generator for standard image files. 

147 

148 This generator creates square thumbnail previews from image files 

149 (PNG, JPEG, TIFF, BMP, GIF) using PIL/Pillow. 

150 """ 

151 

152 name = "image_preview" 

153 priority = 100 

154 supported_extensions: ClassVar = { 

155 "png", 

156 "jpg", 

157 "jpeg", 

158 "tiff", 

159 "tif", 

160 "bmp", 

161 "gif", 

162 } 

163 

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

165 """ 

166 Check if this generator supports the given file. 

167 

168 Parameters 

169 ---------- 

170 context 

171 The extraction context containing file information 

172 

173 Returns 

174 ------- 

175 bool 

176 True if file extension is a supported image format 

177 """ 

178 extension = context.file_path.suffix.lower().lstrip(".") 

179 return extension in self.supported_extensions 

180 

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

182 """ 

183 Generate a square thumbnail preview from an image file. 

184 

185 Parameters 

186 ---------- 

187 context 

188 The extraction context containing file information 

189 output_path 

190 Path where the preview image should be saved 

191 

192 Returns 

193 ------- 

194 bool 

195 True if preview was successfully generated, False otherwise 

196 """ 

197 try: 

198 _logger.debug("Generating image preview for: %s", context.file_path) 

199 

200 # Generate the thumbnail using the local function 

201 return image_to_square_thumbnail( 

202 context.file_path, 

203 output_path, 

204 output_size=500, 

205 ) 

206 

207 except Exception as e: 

208 _logger.warning( 

209 "Failed to generate image preview for %s: %s", 

210 context.file_path, 

211 e, 

212 ) 

213 return False