Source code for neuroconv.datainterfaces.image.imageinterface

"""Interface for converting single or multiple images to NWB format."""

import warnings
from pathlib import Path
from typing import Literal

import numpy as np
from hdmf.data_utils import AbstractDataChunkIterator, DataChunk
from pynwb.base import Image
from pynwb.image import GrayscaleImage, RGBAImage, RGBImage

from .baseimageinterface import BaseImageInterface

# Map PIL image mode -> numpy dtype, for modes supported by ImageInterface.
_PIL_MODE_TO_NUMPY_DTYPE = {
    "L": np.uint8,
    "RGB": np.uint8,
    "RGBA": np.uint8,
    "LA": np.uint8,
    "I;16": np.uint16,
}


[docs] class SingleImageIterator(AbstractDataChunkIterator): """Simple iterator to return a single image. This avoids loading the entire image into memory at initializing and instead loads it at writing time one by one""" def __init__(self, file_path: str | Path): self._file_path = Path(file_path) from PIL import Image # Get image information without loading the full image with Image.open(self._file_path) as img: self.image_mode = img.mode self._image_shape = img.size[::-1] # PIL uses (width, height) instead of (height, width) self.number_of_bands = len(img.getbands()) if self.number_of_bands > 1: self._image_shape += (self.number_of_bands,) # For LA mode, adjust shape to RGBA if self.image_mode == "LA": self._image_shape = self._image_shape[:-1] + (4,) self._dtype = np.dtype(_PIL_MODE_TO_NUMPY_DTYPE.get(self.image_mode, np.uint8)) # Calculate file size in bytes self._size_bytes = self._file_path.stat().st_size # Calculate approximate memory size when loaded as numpy array self._memory_size = np.prod(self._image_shape) * self._dtype.itemsize self._images_returned = 0 # Number of images returned in __next__ def _la_to_rgba(self, la_image: np.ndarray) -> np.ndarray: """Convert a Luminance-Alpha (LA) image to RGBA format without losing information.""" if len(la_image.shape) != 3 or la_image.shape[2] != 2: raise ValueError("Input must be an LA image with shape (height, width, 2)") height, width, _ = la_image.shape rgba_image = np.zeros((height, width, 4), dtype=la_image.dtype) # Extract L and A channels l_channel = la_image[..., 0] a_channel = la_image[..., 1] # Copy L channel to R, G, and B channels rgba_image[..., 0] = l_channel # Red rgba_image[..., 1] = l_channel # Green rgba_image[..., 2] = l_channel # Blue rgba_image[..., 3] = a_channel # Alpha return rgba_image def __iter__(self): """Return the iterator object""" return self def __next__(self): """Return the DataChunk with the single full image""" from PIL import Image if self._images_returned == 0: data = np.asarray(Image.open(self._file_path)) # Transform LA to RGBA if needed if self.image_mode == "LA": data = self._la_to_rgba(data) selection = (slice(None),) * data.ndim self._images_returned += 1 return DataChunk(data=data, selection=selection) else: raise StopIteration
[docs] def recommended_chunk_shape(self): """Recommend the chunk shape for the data array.""" return self._image_shape
[docs] def recommended_data_shape(self): """Recommend the initial shape for the data array.""" return self._image_shape
@property def dtype(self): """Define the data type of the array""" return self._dtype @property def maxshape(self): """Property describing the maximum shape of the data array that is being iterated over""" # A single image has a fixed shape, so the maximum shape is the image shape itself. Reporting concrete # axes (rather than `None`) is also what allows the default chunking and compression estimators in # `tools.nwb_helpers` to size a chunk for this dataset. return self._image_shape def __len__(self): return self._image_shape[0] @property def image_info(self): """Return dictionary with image information""" return { "file_size_bytes": self._size_bytes, "memory_size_bytes": self._memory_size, "shape": self._image_shape, "mode": self.image_mode, "bands": self.number_of_bands, }
[docs] class ImageInterface(BaseImageInterface): """Interface for converting single or multiple images to NWB format.""" display_name = "Image Interface" keywords = ("image",) associated_suffixes = (".png", ".jpg", ".jpeg", ".tiff", ".tif", ".webp") info = "Interface for converting single or multiple images to NWB format." # Mapping from PIL mode to NWB image class IMAGE_MODE_TO_NWB_TYPE_MAP = { "L": GrayscaleImage, # 8 bit grayscale image "RGB": RGBImage, "RGBA": RGBAImage, "LA": RGBAImage, # LA will be converted to RGBA "I;16": GrayscaleImage, # 16-bit grayscale image } def __init__( self, file_paths: list[str | Path] | None = None, folder_path: str | Path | None = None, *args, # TODO: change to * (keyword only) on or after August 2026 images_location: Literal["acquisition", "stimulus"] | None = None, metadata_key: str = "Images", verbose: bool = True, ): """ Initialize the ImageInterface. Parameters ---------- file_paths : list of str | Path, optional List of paths to image files to be converted folder_path : str | Path, optional Path to folder containing images to be converted. Used if file_paths not provided. images_location : Literal["acquisition", "stimulus"], optional Deprecated. Pass ``parent_container`` to ``add_to_nwbfile`` instead. Will be removed in v0.12.0. metadata_key : str, default: "Images" Key to use in metadata["Images"][metadata_key] for storing container metadata verbose : bool, default: True Whether to print status messages """ # Handle deprecated positional arguments if args: parameter_names = [ "images_location", "metadata_key", "verbose", ] num_positional_args_before_args = 2 # file_paths, folder_path if len(args) > len(parameter_names): raise TypeError( f"__init__() takes at most {len(parameter_names) + num_positional_args_before_args + 1} positional arguments but " f"{len(args) + num_positional_args_before_args + 1} were given. " "Note: Positional arguments are deprecated and will be removed on or after August 2026. " "Please use keyword arguments." ) positional_values = dict(zip(parameter_names, args)) passed_as_positional = list(positional_values.keys()) warnings.warn( f"Passing arguments positionally to ImageInterface.__init__() is deprecated " f"and will be removed on or after August 2026. " f"The following arguments were passed positionally: {passed_as_positional}. " "Please use keyword arguments instead.", FutureWarning, stacklevel=2, ) images_location = positional_values.get("images_location", images_location) metadata_key = positional_values.get("metadata_key", metadata_key) verbose = positional_values.get("verbose", verbose) super().__init__( file_paths=file_paths, folder_path=folder_path, metadata_key=metadata_key, verbose=verbose, ) if images_location is not None: warnings.warn( "The 'images_location' parameter of ImageInterface.__init__() is deprecated and will be removed " "in v0.12.0. Pass 'parent_container' to add_to_nwbfile() instead.", FutureWarning, stacklevel=2, ) self.parent_container = images_location def _get_image_metadata_properties(self) -> dict: properties = super()._get_image_metadata_properties() properties["resolution"] = dict( type="number", description="Pixel resolution of the image, in pixels per centimeter." ) return properties def _create_nwb_image(self, *, file_path: Path, image_metadata: dict) -> Image: # Create iterator for memory-efficient loading iterator = SingleImageIterator(file_path) # Validate mode and get image class if iterator.image_mode not in self.IMAGE_MODE_TO_NWB_TYPE_MAP: raise ValueError(f"Unsupported image mode: {iterator.image_mode} for image {file_path.name}") # Build the Image nwb_image_class = self.IMAGE_MODE_TO_NWB_TYPE_MAP[iterator.image_mode] image_kwargs = dict(data=iterator) image_kwargs.update(image_metadata) # If name is not available use the file stem image_kwargs["name"] = image_kwargs.get("name", Path(file_path).stem) return nwb_image_class(**image_kwargs)