"""Image file preview generator."""
import logging
from pathlib import Path
from typing import ClassVar, Tuple
import matplotlib.pyplot as plt
import numpy as np
from PIL import Image, UnidentifiedImageError
from nexusLIMS.extractors.base import ExtractionContext
_logger = logging.getLogger(__name__)
_LANCZOS = Image.Resampling.LANCZOS
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)
[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
-------
bool
Whether a preview was generated
"""
try:
image = Image.open(f)
# For high-bit-depth images (e.g. uint16 TIFFs from scientific instruments),
# apply percentile contrast stretching so the full 8-bit range is used.
# Without this, low-contrast features (e.g. ECCI patterns) disappear entirely.
if image.mode in ("I", "I;16", "I;16B") or (
hasattr(image, "mode") and "16" in str(image.mode)
):
arr = np.array(image, dtype=np.float32)
lo, hi = np.nanpercentile(arr, [2, 98])
if hi > lo:
arr = np.clip((arr - lo) / (hi - lo) * 255, 0, 255).astype(np.uint8)
else:
arr = np.zeros_like(arr, dtype=np.uint8)
image = Image.fromarray(arr, mode="L")
image.save(out_path)
_pad_to_square(out_path, output_size)
except UnidentifiedImageError as exc:
_logger.warning("no preview generated; PIL error text: %s", str(exc))
if out_path.exists():
out_path.unlink()
return False
return True
[docs]
def down_sample_image(
fname: Path,
out_path: Path,
output_size: Tuple[int, int] | None = None,
factor: int | None = 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
[docs]
class ImagePreviewGenerator:
"""
Preview generator for standard image files.
This generator creates square thumbnail previews from image files
(PNG, JPEG, TIFF, BMP, GIF) using PIL/Pillow.
"""
name = "image_preview"
priority = 100
supported_extensions: ClassVar = {
"png",
"jpg",
"jpeg",
"tiff",
"tif",
"bmp",
"gif",
}
[docs]
def supports(self, context: ExtractionContext) -> bool:
"""
Check if this generator supports the given file.
Parameters
----------
context
The extraction context containing file information
Returns
-------
bool
True if file extension is a supported image format
"""
extension = context.file_path.suffix.lower().lstrip(".")
return extension in self.supported_extensions
[docs]
def generate(self, context: ExtractionContext, output_path: Path) -> bool:
"""
Generate a square thumbnail preview from an image file.
Parameters
----------
context
The extraction context containing file information
output_path
Path where the preview image should be saved
Returns
-------
bool
True if preview was successfully generated, False otherwise
"""
try:
_logger.debug("Generating image preview for: %s", context.file_path)
# Generate the thumbnail using the local function
return image_to_square_thumbnail(
context.file_path,
output_path,
output_size=500,
)
except Exception as e:
_logger.warning(
"Failed to generate image preview for %s: %s",
context.file_path,
e,
)
return False