From 41774f7af83f8c34883dc80f8cf030317baf6719 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Tue, 4 Feb 2025 15:49:14 -0600 Subject: [PATCH 1/9] add tests and draft of interface --- src/neuroconv/datainterfaces/__init__.py | 8 + .../datainterfaces/image/__init__.py | 5 + .../datainterfaces/image/imageinterface.py | 248 ++++++++++++++++++ tests/test_image/test_image_interface.py | 121 +++++++++ 4 files changed, 382 insertions(+) create mode 100644 src/neuroconv/datainterfaces/image/__init__.py create mode 100644 src/neuroconv/datainterfaces/image/imageinterface.py create mode 100644 tests/test_image/test_image_interface.py diff --git a/src/neuroconv/datainterfaces/__init__.py b/src/neuroconv/datainterfaces/__init__.py index bf5df908e..1c7a40fbb 100644 --- a/src/neuroconv/datainterfaces/__init__.py +++ b/src/neuroconv/datainterfaces/__init__.py @@ -92,6 +92,9 @@ from .ophys.tdt_fp.tdtfiberphotometrydatainterface import TDTFiberPhotometryInterface from .ophys.tiff.tiffdatainterface import TiffImagingInterface +# Image +from .image.imageinterface import ImageInterface + # Text from .text.csv.csvtimeintervalsinterface import CsvTimeIntervalsInterface from .text.excel.exceltimeintervalsinterface import ExcelTimeIntervalsInterface @@ -164,6 +167,8 @@ # Text CsvTimeIntervalsInterface, ExcelTimeIntervalsInterface, + # Image + ImageInterface, ] interfaces_by_category = dict( @@ -200,4 +205,7 @@ ExcelTimeIntervals=ExcelTimeIntervalsInterface, MedPC=MedPCInterface, ), + image=dict( + Image=ImageInterface, + ), ) diff --git a/src/neuroconv/datainterfaces/image/__init__.py b/src/neuroconv/datainterfaces/image/__init__.py new file mode 100644 index 000000000..9d0d21dab --- /dev/null +++ b/src/neuroconv/datainterfaces/image/__init__.py @@ -0,0 +1,5 @@ +"""Image data interfaces.""" + +from .imageinterface import ImageInterface + +__all__ = ["ImageInterface"] diff --git a/src/neuroconv/datainterfaces/image/imageinterface.py b/src/neuroconv/datainterfaces/image/imageinterface.py new file mode 100644 index 000000000..3d049e7d3 --- /dev/null +++ b/src/neuroconv/datainterfaces/image/imageinterface.py @@ -0,0 +1,248 @@ +"""Interface for converting single or multiple images to NWB format.""" + +from pathlib import Path +from typing import List, Literal, Optional + +import numpy as np +from hdmf.data_utils import AbstractDataChunkIterator, DataChunk +from PIL import Image +from pynwb import NWBFile +from pynwb.base import Images +from pynwb.image import GrayscaleImage, RGBAImage, RGBImage + +from ...basedatainterface import BaseDataInterface +from ...utils import DeepDict + + +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, filename): + self._filename = Path(filename) + + # Get image information without loading the full image + with Image.open(self._filename) as img: + self.image_mode = img.mode + self._image_shape = img.size[::-1] # PIL uses (width, height) instead of (height, width) + self._max_shape = (None, None) + + self.number_of_bands = len(img.getbands()) + if self.number_of_bands > 1: + self._image_shape += (self.number_of_bands,) + self._max_shape += (self.number_of_bands,) + + # Calculate file size in bytes + self._size_bytes = self._filename.stat().st_size + # Calculate approximate memory size when loaded as numpy array + self._memory_size = np.prod(self._image_shape) * np.dtype(float).itemsize + + self._images_returned = 0 # Number of images returned in __next__ + + def __iter__(self): + """Return the iterator object""" + return self + + def __next__(self): + """Return the DataChunk with the single full image""" + if self._images_returned == 0: + data = np.asarray(Image.open(self._filename)) + selection = (slice(None),) * data.ndim + self._images_returned += 1 + return DataChunk(data=data, selection=selection) + else: + raise StopIteration + + def recommended_chunk_shape(self): + """Recommend the chunk shape for the data array.""" + return self._image_shape + + 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 np.dtype(float) + + @property + def maxshape(self): + """Property describing the maximum shape of the data array that is being iterated over""" + return self._max_shape + + def __len__(self): + return self._image_shape[0] + + @property + def size_info(self): + """Return dictionary with size 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, + } + + +class ImageInterface(BaseDataInterface): + """Interface for converting single or multiple images to NWB format.""" + + display_name = "Image Interface" + keywords = ("image",) + associated_suffixes = (".png", ".jpg", ".jpeg", ".tiff", ".tif") + info = "Interface for converting single or multiple images to NWB format." + + @classmethod + def get_source_schema(cls) -> dict: + """Return the schema for the source_data.""" + return dict( + required=["file_paths"], + properties=dict( + file_paths=dict( + type=["array", "string"], + items=dict(type="string"), + description="Path(s) to image file(s) to be converted", + ), + folder_path=dict( + type="string", + description="Path to folder containing images to be converted. Used if file_paths not provided.", + ), + ), + ) + + def __init__( + self, + file_paths: Optional[List[str]] = None, + folder_path: Optional[str] = None, + images_location: Literal["acquisition", "stimulus"] = "acquisition", + verbose: bool = True, + ): + """ + Initialize the ImageInterface. + + Parameters + ---------- + file_paths : str or list of str, optional + Path(s) to image file(s) to be converted + folder_path : str, optional + Path to folder containing images to be converted. Used if file_paths not provided. + verbose : bool, default: True + Whether to print status messages + """ + if file_paths is None and folder_path is None: + raise ValueError("Either file_paths or folder_path must be provided") + + if file_paths is not None and folder_path is not None: + raise ValueError("Only one of file_paths or folder_path should be provided") + + if isinstance(file_paths, str): + file_paths = [file_paths] + + self.file_paths = file_paths + self.folder_path = folder_path + self.images_location = images_location + + super().__init__( + verbose=verbose, file_paths=file_paths, folder_path=folder_path, images_location=images_location + ) + + # Process paths + if folder_path is not None: + folder = Path(folder_path) + if not folder.exists(): + raise ValueError(f"Folder {folder} does not exist") + + # Get all image files in folder + file_paths = [] + for suffix in self.associated_suffixes: + file_paths.extend(folder.glob(f"*{suffix}")) + + if not file_paths: + raise ValueError(f"No image files found in {folder}") + + self.file_paths = [str(p) for p in file_paths] + else: + self.file_paths = [str(Path(p).absolute()) for p in file_paths] + + # Validate paths + for path in self.file_paths: + if not Path(path).exists(): + raise ValueError(f"File {path} does not exist") + + def get_metadata(self) -> DeepDict: + """Get metadata for the images.""" + metadata = super().get_metadata() + + # Add basic metadata about the images + metadata["Images"] = dict(description="Images loaded through ImageInterface", num_images=len(self.file_paths)) + + return metadata + + def _get_image_container_type(self, image_path: str) -> Literal["GrayscaleImage", "RGBImage", "RGBAImage"]: + """Determine the appropriate image container type based on the image mode.""" + with Image.open(image_path) as img: + mode = img.mode + + if mode == "L": + return "GrayscaleImage" + elif mode == "RGB": + return "RGBImage" + elif mode == "RGBA": + return "RGBAImage" + else: + raise ValueError(f"Unsupported image mode: {mode}") + + def add_to_nwbfile( + self, + nwbfile: NWBFile, + metadata: Optional[dict] = None, + container_name: str = "images", + ) -> None: + """ + Add the image data to an NWB file. + + Parameters + ---------- + nwbfile : NWBFile + The NWB file to add the images to + metadata : dict, optional + Metadata for the images + container_name : str, default: "images" + Name of the Images container + """ + if metadata is None: + metadata = self.get_metadata() + + # Create Images container + images_container = Images( + name=container_name, + description=metadata.get("Images", {}).get("description", "Images loaded through ImageInterface"), + ) + + # Process each image + for file_path in self.file_paths: + # Create iterator for memory-efficient loading + iterator = SingleImageIterator(file_path) + + # Get image name from file name + image_name = Path(file_path).stem + + # Determine image type and create appropriate container + container_type = self._get_image_container_type(file_path) + image_class = {"GrayscaleImage": GrayscaleImage, "RGBImage": RGBImage, "RGBAImage": RGBAImage}[ + container_type + ] + + # Create image container with iterator + image_container = image_class(name=image_name, data=iterator) + + # Add to images container + images_container.add_image(image_container) + + # Add images container to file + if self.images_location == "acquisition": + nwbfile.add_acquisition(images_container) + else: + nwbfile.add_stimulus(images_container) diff --git a/tests/test_image/test_image_interface.py b/tests/test_image/test_image_interface.py new file mode 100644 index 000000000..7d59bb285 --- /dev/null +++ b/tests/test_image/test_image_interface.py @@ -0,0 +1,121 @@ +from pathlib import Path + +import numpy as np +import pytest +from PIL import Image +from pynwb.image import RGBImage + +from neuroconv.datainterfaces.image.imageinterface import ImageInterface +from neuroconv.tools.testing.data_interface_mixins import DataInterfaceTestMixin + + +def generate_random_images( + num_images, width=256, height=256, mode="RGB", seed=None, output_dir="generated_images", format="PNG" +): + """ + Generate random images using numpy arrays and save them in the specified format. + + Parameters: + ----------- + num_images : int + Number of images to generate + width : int + Width of the images (default: 256) + height : int + Height of the images (default: 256) + mode : str + Image mode: '1', 'L', 'P', 'RGB', 'RGBA', 'CMYK', 'YCbCr', 'LAB', 'HSV', + 'I', 'F', 'LA', 'PA', 'RGBX', 'RGBa', 'La', 'I;16', 'I;16L', 'I;16B', 'I;16N' + seed : int or None + Random seed for reproducibility (default: None) + output_dir : str + Directory to save the generated images (default: "generated_images") + format : str + Output format: 'PNG', 'JPEG', 'TIFF', 'BMP', 'WEBP', etc. (default: 'PNG') + """ + mode_configs = { + "1": {"channels": 1, "dtype": np.uint8, "max_val": 1}, + "L": {"channels": 1, "dtype": np.uint8, "max_val": 255}, + "P": {"channels": 1, "dtype": np.uint8, "max_val": 255}, + "RGB": {"channels": 3, "dtype": np.uint8, "max_val": 255}, + "RGBA": {"channels": 4, "dtype": np.uint8, "max_val": 255}, + "CMYK": {"channels": 4, "dtype": np.uint8, "max_val": 255}, + "YCbCr": {"channels": 3, "dtype": np.uint8, "max_val": 255}, + "LAB": {"channels": 3, "dtype": np.uint8, "max_val": 255}, + "HSV": {"channels": 3, "dtype": np.uint8, "max_val": 255}, + "I": {"channels": 1, "dtype": np.int32, "max_val": 2**31 - 1}, + "F": {"channels": 1, "dtype": np.float32, "max_val": 1.0}, + "LA": {"channels": 2, "dtype": np.uint8, "max_val": 255}, + "PA": {"channels": 2, "dtype": np.uint8, "max_val": 255}, + "RGBX": {"channels": 4, "dtype": np.uint8, "max_val": 255}, + "RGBa": {"channels": 4, "dtype": np.uint8, "max_val": 255}, + "La": {"channels": 2, "dtype": np.uint8, "max_val": 255}, + "I;16": {"channels": 1, "dtype": np.uint16, "max_val": 65535}, + "I;16L": {"channels": 1, "dtype": np.uint16, "max_val": 65535}, + "I;16B": {"channels": 1, "dtype": np.uint16, "max_val": 65535}, + "I;16N": {"channels": 1, "dtype": np.uint16, "max_val": 65535}, + } + + if mode not in mode_configs: + raise ValueError(f"Mode must be one of {list(mode_configs.keys())}") + + rng = np.random.default_rng(seed) + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + for file in output_dir.iterdir(): + if file.is_file(): + file.unlink() + + config = mode_configs[mode] + format_ext = format.lower() + + for i in range(num_images): + shape = [height, width] + if config["channels"] > 1: + shape.append(config["channels"]) + + if config["dtype"] in [np.uint8, np.uint16, np.int32]: + array = rng.integers(0, config["max_val"] + 1, shape, dtype=config["dtype"]) + else: # float32 + array = rng.random(shape, dtype=config["dtype"]) + + if mode == "HSV": + array[..., 0] = (array[..., 0].astype(float) / 255 * 360).astype(np.uint8) + elif mode == "P": + palette = rng.integers(0, 256, (256, 3), dtype=np.uint8) + + image = Image.fromarray(array, mode=mode) + if mode == "P": + image.putpalette(palette.flatten()) + + filename = f"{output_dir}/image{i}.{format_ext}" + image.save(filename, format=format) + + +class TestImageInterface(DataInterfaceTestMixin): + """Test suite for ImageInterface with RGB images.""" + + data_interface_cls = ImageInterface + + @pytest.fixture(autouse=True) + def make_interface(self, tmp_path): + """Create interface with RGB test images.""" + # Generate test RGB images + generate_random_images(num_images=10, mode="RGB", output_dir=str(tmp_path)) + self.interface_kwargs = dict(folder_path=str(tmp_path)) + self.interface = self.data_interface_cls(**self.interface_kwargs) + + def check_read_nwb(self, nwbfile_path): + """Test adding RGB images to NWBFile.""" + + from pynwb import NWBHDF5IO + + with NWBHDF5IO(nwbfile_path, "r") as io: + nwbfile = io.read() + # Check images were added correctly + assert "images" in nwbfile.acquisition + images_container = nwbfile.acquisition["images"] + assert len(images_container.images) == 10 + for image in images_container.images.values(): + assert isinstance(image, RGBImage) From 13f5f9cde255a9955db5164ca570fb8be2a2a553 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Fri, 7 Feb 2025 09:02:19 -0600 Subject: [PATCH 2/9] fix imports --- tests/imports.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/imports.py b/tests/imports.py index 7ac95713b..246a81ff6 100644 --- a/tests/imports.py +++ b/tests/imports.py @@ -74,7 +74,7 @@ def test_tools(self): ] assert sorted(current_structure) == sorted(expected_structure) - def test_datainterfaces(self): + def test_data_interfaces(self): from neuroconv import datainterfaces current_structure = _strip_magic_module_attributes(ls=datainterfaces.__dict__) @@ -89,6 +89,7 @@ def test_datainterfaces(self): "icephys", "ophys", "text", + "image", # Exposed attributes "interface_list", "interfaces_by_category", From ee140344e9cef55a759db423f0d5edfc2633731f Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Fri, 7 Feb 2025 09:05:20 -0600 Subject: [PATCH 3/9] changelog --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c325237b4..1951b6b2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,14 +5,15 @@ * Added `metadata` and `conversion_options` as arguments to `NWBConverter.temporally_align_data_interfaces` [PR #1162](https://github.com/catalystneuro/neuroconv/pull/1162) ## Bug Fixes -* `run_conversion` does not longer trigger append mode an index error when `nwbfile_path` points to a faulty file [PR #1180](https://github.com/catalystneuro/neuroconv/pull/1180) +* `run_conversion` does not longer trigger append mode when `nwbfile_path` points to a faulty file [PR #1180](https://github.com/catalystneuro/neuroconv/pull/1180) ## Features * Use the latest version of ndx-pose for `DeepLabCutInterface` and `LightningPoseDataInterface` [PR #1128](https://github.com/catalystneuro/neuroconv/pull/1128) +* Added `ImageInterface` for writing large collection of images to NWB and automatically map the images to the correct NWB data types [PR #1190](https://github.com/catalystneuro/neuroconv/pull/1190) ## Improvements * Simple writing no longer uses a context manager [PR #1180](https://github.com/catalystneuro/neuroconv/pull/1180) -* ElectricalSeries have better chunking defaults when data is passed as plain array [PR #1184](https://github.com/catalystneuro/neuroconv/pull/1184) +* ElectricalSeries have better chunking defaults when data is passed as an in-memory array [PR #1184](https://github.com/catalystneuro/neuroconv/pull/1184) # v0.6.7 (January 20, 2025) From af01d943fd012314eddaf3e628d336b0894e992a Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Fri, 7 Feb 2025 10:16:49 -0600 Subject: [PATCH 4/9] changelog --- tests/imports.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/imports.py b/tests/imports.py index 246a81ff6..f2d5b7a62 100644 --- a/tests/imports.py +++ b/tests/imports.py @@ -74,7 +74,7 @@ def test_tools(self): ] assert sorted(current_structure) == sorted(expected_structure) - def test_data_interfaces(self): + def test_datainterfaces(self): from neuroconv import datainterfaces current_structure = _strip_magic_module_attributes(ls=datainterfaces.__dict__) From 8399959cd3f8d7b0cab6decba4c44b382cedffb5 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Mon, 10 Feb 2025 14:24:15 -0600 Subject: [PATCH 5/9] add support for LA to RGBA --- docs/user_guide/image.rst | 118 ++++++++++++ docs/user_guide/index.rst | 1 + pyproject.toml | 6 + .../datainterfaces/image/imageinterface.py | 95 ++++++---- tests/test_image/test_image_interface.py | 172 ++++++++++++++++-- 5 files changed, 341 insertions(+), 51 deletions(-) create mode 100644 docs/user_guide/image.rst diff --git a/docs/user_guide/image.rst b/docs/user_guide/image.rst new file mode 100644 index 000000000..95139fd5f --- /dev/null +++ b/docs/user_guide/image.rst @@ -0,0 +1,118 @@ +Image Interface +============== + +The ImageInterface allows you to convert various image formats (PNG, JPG, TIFF) to NWB. It supports different color modes and efficiently handles image loading to minimize memory usage. + +Supported Image Modes +------------------- + +The interface supports the following PIL image modes: + +- L (grayscale) → GrayscaleImage +- RGB → RGBImage +- RGBA → RGBAImage +- LA (luminance + alpha) → RGBAImage (automatically converted) + +Example Usage +------------ + +Here's an example demonstrating how to use the ImageInterface with different image modes: + +.. code-block:: python + + from datetime import datetime + from pathlib import Path + from neuroconv.datainterfaces import ImageInterface + from pynwb import NWBHDF5IO, NWBFile + + # Create example images of different modes + from PIL import Image + import numpy as np + + # Create a temporary directory for our example images + from tempfile import mkdtemp + image_dir = Path(mkdtemp()) + + # Create example images + # RGB image (3 channels) + rgb_array = np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8) + rgb_image = Image.fromarray(rgb_array, mode='RGB') + rgb_image.save(image_dir / 'rgb_image.png') + + # Grayscale image (L mode) + gray_array = np.random.randint(0, 255, (100, 100), dtype=np.uint8) + gray_image = Image.fromarray(gray_array, mode='L') + gray_image.save(image_dir / 'gray_image.png') + + # RGBA image (4 channels) + rgba_array = np.random.randint(0, 255, (100, 100, 4), dtype=np.uint8) + rgba_image = Image.fromarray(rgba_array, mode='RGBA') + rgba_image.save(image_dir / 'rgba_image.png') + + # LA image (luminance + alpha) + la_array = np.random.randint(0, 255, (100, 100, 2), dtype=np.uint8) + la_image = Image.fromarray(la_array, mode='LA') + la_image.save(image_dir / 'la_image.png') + + # Initialize the image interface + interface = ImageInterface(folder_path=str(image_dir)) + + # Create a basic NWBFile + nwbfile = NWBFile( + session_description="Image interface example session", + identifier="IMAGE123", + session_start_time=datetime.now().astimezone(), + experimenter="Dr. John Doe", + lab="Image Processing Lab", + institution="Neural Image Institute", + experiment_description="Example experiment demonstrating image conversion", + ) + + # Add the images to the NWB file + interface.add_to_nwbfile(nwbfile) + + # Write the NWB file + nwb_path = Path("image_example.nwb") + with NWBHDF5IO(nwb_path, "w") as io: + io.write(nwbfile) + + # Read the NWB file to verify + with NWBHDF5IO(nwb_path, "r") as io: + nwbfile = io.read() + # Access the images container + images_container = nwbfile.acquisition["images"] + print(f"Number of images: {len(images_container.images)}") + # Print information about each image + for name, image in images_container.images.items(): + print(f"\nImage name: {name}") + print(f"Image type: {type(image).__name__}") + print(f"Image shape: {image.data.shape}") + +Key Features +----------- + +1. **Memory Efficiency**: Uses an iterator pattern to load images only when needed, making it suitable for large images or multiple images. + +2. **Automatic Mode Conversion**: Handles LA (luminance + alpha) to RGBA conversion automatically while maintaining image information. + +3. **Input Methods**: + - List of files: ``interface = ImageInterface(file_paths=["image1.png", "image2.jpg"])`` + - Directory: ``interface = ImageInterface(folder_path="images_directory")`` + +4. **Flexible Storage Location**: Images can be stored in either acquisition or stimulus: + .. code-block:: python + + # Store in acquisition (default) + interface = ImageInterface(file_paths=["image.png"], images_location="acquisition") + + # Store in stimulus + interface = ImageInterface(file_paths=["image.png"], images_location="stimulus") + +Installation +----------- + +To use the ImageInterface, install neuroconv with the image extra: + +.. code-block:: bash + + pip install "neuroconv[image]" diff --git a/docs/user_guide/index.rst b/docs/user_guide/index.rst index bf9aaf253..14dbd1aea 100644 --- a/docs/user_guide/index.rst +++ b/docs/user_guide/index.rst @@ -19,6 +19,7 @@ and synchronize data across multiple sources. :maxdepth: 2 datainterfaces + image nwbconverter adding_trials temporal_alignment diff --git a/pyproject.toml b/pyproject.toml index 86041202b..f35ab1819 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -271,6 +271,11 @@ icephys = [ "neuroconv[abf]", ] +## Image +image = [ + "pillow>=10.0.0", # PIL +] + ## Ophys brukertiff = [ "roiextractors>=0.5.10", @@ -342,6 +347,7 @@ full = [ "neuroconv[behavior]", "neuroconv[ecephys]", "neuroconv[icephys]", + "neuroconv[image]", "neuroconv[ophys]", "neuroconv[text]", ] diff --git a/src/neuroconv/datainterfaces/image/imageinterface.py b/src/neuroconv/datainterfaces/image/imageinterface.py index 3d049e7d3..06f864037 100644 --- a/src/neuroconv/datainterfaces/image/imageinterface.py +++ b/src/neuroconv/datainterfaces/image/imageinterface.py @@ -1,7 +1,7 @@ """Interface for converting single or multiple images to NWB format.""" from pathlib import Path -from typing import List, Literal, Optional +from typing import List, Literal, Optional, Union import numpy as np from hdmf.data_utils import AbstractDataChunkIterator, DataChunk @@ -18,11 +18,11 @@ 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, filename): - self._filename = Path(filename) + def __init__(self, file_path: Union[str, Path]): + self._file_path = Path(file_path) # Get image information without loading the full image - with Image.open(self._filename) as img: + 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._max_shape = (None, None) @@ -32,13 +32,38 @@ def __init__(self, filename): self._image_shape += (self.number_of_bands,) self._max_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._max_shape = self._max_shape[:-1] + (4,) + # Calculate file size in bytes - self._size_bytes = self._filename.stat().st_size + 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) * np.dtype(float).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 @@ -46,7 +71,12 @@ def __iter__(self): def __next__(self): """Return the DataChunk with the single full image""" if self._images_returned == 0: - data = np.asarray(Image.open(self._filename)) + 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) @@ -94,6 +124,14 @@ class ImageInterface(BaseDataInterface): associated_suffixes = (".png", ".jpg", ".jpeg", ".tiff", ".tif") info = "Interface for converting single or multiple images to NWB format." + # Mapping from PIL mode to NWB image class + MODE_MAPPING = { + "L": GrayscaleImage, + "RGB": RGBImage, + "RGBA": RGBAImage, + "LA": RGBAImage, # LA will be converted to RGBA + } + @classmethod def get_source_schema(cls) -> dict: """Return the schema for the source_data.""" @@ -101,9 +139,9 @@ def get_source_schema(cls) -> dict: required=["file_paths"], properties=dict( file_paths=dict( - type=["array", "string"], + type="array", items=dict(type="string"), - description="Path(s) to image file(s) to be converted", + description="List of paths to image files to be converted", ), folder_path=dict( type="string", @@ -114,8 +152,8 @@ def get_source_schema(cls) -> dict: def __init__( self, - file_paths: Optional[List[str]] = None, - folder_path: Optional[str] = None, + file_paths: Optional[List[Union[str, Path]]] = None, + folder_path: Optional[Union[str, Path]] = None, images_location: Literal["acquisition", "stimulus"] = "acquisition", verbose: bool = True, ): @@ -124,10 +162,12 @@ def __init__( Parameters ---------- - file_paths : str or list of str, optional - Path(s) to image file(s) to be converted - folder_path : str, optional + file_paths : list of Union[str, Path], optional + List of paths to image files to be converted + folder_path : Union[str, Path], optional Path to folder containing images to be converted. Used if file_paths not provided. + images_location : Literal["acquisition", "stimulus"], default: "acquisition" + Location to store images in the NWB file verbose : bool, default: True Whether to print status messages """ @@ -137,9 +177,6 @@ def __init__( if file_paths is not None and folder_path is not None: raise ValueError("Only one of file_paths or folder_path should be provided") - if isinstance(file_paths, str): - file_paths = [file_paths] - self.file_paths = file_paths self.folder_path = folder_path self.images_location = images_location @@ -162,7 +199,7 @@ def __init__( if not file_paths: raise ValueError(f"No image files found in {folder}") - self.file_paths = [str(p) for p in file_paths] + self.file_paths = [str(Path(p).absolute()) for p in file_paths] else: self.file_paths = [str(Path(p).absolute()) for p in file_paths] @@ -180,20 +217,6 @@ def get_metadata(self) -> DeepDict: return metadata - def _get_image_container_type(self, image_path: str) -> Literal["GrayscaleImage", "RGBImage", "RGBAImage"]: - """Determine the appropriate image container type based on the image mode.""" - with Image.open(image_path) as img: - mode = img.mode - - if mode == "L": - return "GrayscaleImage" - elif mode == "RGB": - return "RGBImage" - elif mode == "RGBA": - return "RGBAImage" - else: - raise ValueError(f"Unsupported image mode: {mode}") - def add_to_nwbfile( self, nwbfile: NWBFile, @@ -229,13 +252,11 @@ def add_to_nwbfile( # Get image name from file name image_name = Path(file_path).stem - # Determine image type and create appropriate container - container_type = self._get_image_container_type(file_path) - image_class = {"GrayscaleImage": GrayscaleImage, "RGBImage": RGBImage, "RGBAImage": RGBAImage}[ - container_type - ] + # Validate mode and get image class + if iterator.image_mode not in self.MODE_MAPPING: + raise ValueError(f"Unsupported image mode: {iterator.image_mode}") - # Create image container with iterator + image_class = self.MODE_MAPPING[iterator.image_mode] image_container = image_class(name=image_name, data=iterator) # Add to images container diff --git a/tests/test_image/test_image_interface.py b/tests/test_image/test_image_interface.py index 7d59bb285..4f1eced98 100644 --- a/tests/test_image/test_image_interface.py +++ b/tests/test_image/test_image_interface.py @@ -3,14 +3,14 @@ import numpy as np import pytest from PIL import Image -from pynwb.image import RGBImage +from pynwb.image import GrayscaleImage, RGBAImage, RGBImage from neuroconv.datainterfaces.image.imageinterface import ImageInterface from neuroconv.tools.testing.data_interface_mixins import DataInterfaceTestMixin def generate_random_images( - num_images, width=256, height=256, mode="RGB", seed=None, output_dir="generated_images", format="PNG" + num_images, width=256, height=256, mode="RGB", seed=None, output_dir_path="generated_images", format="PNG" ): """ Generate random images using numpy arrays and save them in the specified format. @@ -28,7 +28,7 @@ def generate_random_images( 'I', 'F', 'LA', 'PA', 'RGBX', 'RGBa', 'La', 'I;16', 'I;16L', 'I;16B', 'I;16N' seed : int or None Random seed for reproducibility (default: None) - output_dir : str + output_dir_path : str Directory to save the generated images (default: "generated_images") format : str Output format: 'PNG', 'JPEG', 'TIFF', 'BMP', 'WEBP', etc. (default: 'PNG') @@ -60,10 +60,10 @@ def generate_random_images( raise ValueError(f"Mode must be one of {list(mode_configs.keys())}") rng = np.random.default_rng(seed) - output_dir = Path(output_dir) - output_dir.mkdir(parents=True, exist_ok=True) + output_dir_path = Path(output_dir_path) + output_dir_path.mkdir(parents=True, exist_ok=True) - for file in output_dir.iterdir(): + for file in output_dir_path.iterdir(): if file.is_file(): file.unlink() @@ -88,27 +88,26 @@ def generate_random_images( image = Image.fromarray(array, mode=mode) if mode == "P": image.putpalette(palette.flatten()) - - filename = f"{output_dir}/image{i}.{format_ext}" + filename = output_dir_path / f"image{i}_{format}_{mode}.{format_ext}" image.save(filename, format=format) -class TestImageInterface(DataInterfaceTestMixin): +@pytest.mark.parametrize("format", ["PNG", "JPEG", "TIFF"]) +class TestRGBImageInterface(DataInterfaceTestMixin): """Test suite for ImageInterface with RGB images.""" data_interface_cls = ImageInterface @pytest.fixture(autouse=True) - def make_interface(self, tmp_path): + def make_interface(self, tmp_path, format): """Create interface with RGB test images.""" # Generate test RGB images - generate_random_images(num_images=10, mode="RGB", output_dir=str(tmp_path)) - self.interface_kwargs = dict(folder_path=str(tmp_path)) + generate_random_images(num_images=5, mode="RGB", output_dir_path=tmp_path, format=format) + self.interface_kwargs = dict(folder_path=tmp_path) self.interface = self.data_interface_cls(**self.interface_kwargs) def check_read_nwb(self, nwbfile_path): """Test adding RGB images to NWBFile.""" - from pynwb import NWBHDF5IO with NWBHDF5IO(nwbfile_path, "r") as io: @@ -116,6 +115,151 @@ def check_read_nwb(self, nwbfile_path): # Check images were added correctly assert "images" in nwbfile.acquisition images_container = nwbfile.acquisition["images"] - assert len(images_container.images) == 10 + assert len(images_container.images) == 5 for image in images_container.images.values(): assert isinstance(image, RGBImage) + + +@pytest.mark.parametrize("format", ["PNG", "JPEG", "TIFF"]) +class TestGrayscaleImageInterface(DataInterfaceTestMixin): + """Test suite for ImageInterface with grayscale (mode L) images.""" + + data_interface_cls = ImageInterface + + @pytest.fixture(autouse=True) + def make_interface(self, tmp_path, format): + """Create interface with grayscale test images.""" + # Generate test grayscale images + generate_random_images(num_images=5, mode="L", output_dir_path=tmp_path, format=format) + self.interface_kwargs = dict(folder_path=tmp_path) + self.interface = self.data_interface_cls(**self.interface_kwargs) + + def check_read_nwb(self, nwbfile_path): + """Test adding grayscale images to NWBFile.""" + from pynwb import NWBHDF5IO + + with NWBHDF5IO(nwbfile_path, "r") as io: + nwbfile = io.read() + # Check images were added correctly + assert "images" in nwbfile.acquisition + images_container = nwbfile.acquisition["images"] + assert len(images_container.images) == 5 + for image in images_container.images.values(): + assert isinstance(image, GrayscaleImage) + + +@pytest.mark.parametrize("format", ["PNG", "TIFF"]) # JPEG doesn't support RGBA +class TestRGBAImageInterface(DataInterfaceTestMixin): + """Test suite for ImageInterface with RGBA images.""" + + data_interface_cls = ImageInterface + + @pytest.fixture(autouse=True) + def make_interface(self, tmp_path, format): + """Create interface with RGBA test images.""" + # Generate test RGBA images + generate_random_images(num_images=5, mode="RGBA", output_dir_path=tmp_path, format=format) + self.interface_kwargs = dict(folder_path=tmp_path) + self.interface = self.data_interface_cls(**self.interface_kwargs) + + def check_read_nwb(self, nwbfile_path): + """Test adding RGBA images to NWBFile.""" + from pynwb import NWBHDF5IO + + with NWBHDF5IO(nwbfile_path, "r") as io: + nwbfile = io.read() + # Check images were added correctly + assert "images" in nwbfile.acquisition + images_container = nwbfile.acquisition["images"] + assert len(images_container.images) == 5 + for image in images_container.images.values(): + assert isinstance(image, RGBAImage) + + +@pytest.mark.parametrize("format", ["PNG", "TIFF"]) # JPEG doesn't support LA +class TestLAtoRGBAImageInterface(DataInterfaceTestMixin): + """Test suite for ImageInterface with LA images being converted to RGBA.""" + + data_interface_cls = ImageInterface + + @pytest.fixture(autouse=True) + def make_interface(self, tmp_path, format): + """Create interface with LA test images.""" + # Generate test LA images + generate_random_images(num_images=5, mode="LA", output_dir_path=tmp_path, format=format) + self.interface_kwargs = dict(folder_path=tmp_path) + self.interface = self.data_interface_cls(**self.interface_kwargs) + + def check_read_nwb(self, nwbfile_path): + """Test adding LA images to NWBFile and verifying they are converted to RGBA.""" + from pynwb import NWBHDF5IO + + with NWBHDF5IO(nwbfile_path, "r") as io: + nwbfile = io.read() + # Check images were added correctly + assert "images" in nwbfile.acquisition + images_container = nwbfile.acquisition["images"] + assert len(images_container.images) == 5 + for image in images_container.images.values(): + assert isinstance(image, RGBAImage) + # Verify the data shape is correct for RGBA (height, width, 4) + assert image.data.shape[-1] == 4 + # Verify R, G, B channels are equal (since they come from L channel) + assert np.all(image.data[..., 0] == image.data[..., 1]) + assert np.all(image.data[..., 1] == image.data[..., 2]) + + +class TestMixedImagesInterface(DataInterfaceTestMixin): + """Test suite for ImageInterface with mixed image modes and formats.""" + + data_interface_cls = ImageInterface + + @pytest.fixture(autouse=True) + def make_interface(self, tmp_path): + """Create interface with mixed test images.""" + # Generate test images of different modes and formats + # Create subdirectories for each format to avoid name collisions + rgb_dir = tmp_path / "rgb" + gray_dir = tmp_path / "gray" + rgba_dir = tmp_path / "rgba" + la_dir = tmp_path / "la" + + # Generate test images in different directories + generate_random_images(num_images=2, mode="RGB", output_dir_path=rgb_dir, format="PNG") + generate_random_images(num_images=2, mode="L", output_dir_path=gray_dir, format="PNG") + generate_random_images(num_images=2, mode="RGBA", output_dir_path=rgba_dir, format="TIFF") + generate_random_images(num_images=2, mode="LA", output_dir_path=la_dir, format="PNG") + + # Collect all image paths + file_paths = [] + for dir_path in [rgb_dir, gray_dir, rgba_dir, la_dir]: + file_paths.extend([str(p) for p in dir_path.glob("*.*")]) + + self.interface_kwargs = dict(file_paths=file_paths) + self.interface = self.data_interface_cls(file_paths=file_paths) + + def check_read_nwb(self, nwbfile_path): + """Test adding mixed images to NWBFile.""" + from pynwb import NWBHDF5IO + + with NWBHDF5IO(nwbfile_path, "r") as io: + nwbfile = io.read() + # Check images were added correctly + assert "images" in nwbfile.acquisition + images_container = nwbfile.acquisition["images"] + assert len(images_container.images) == 8 + + # Count instances of each image type + image_types = { + RGBImage: 0, + GrayscaleImage: 0, + RGBAImage: 0, # This will include both RGBA and converted LA images + } + + for image in images_container.images.values(): + image_types[type(image)] += 1 + + # Verify we have the expected number of each type + assert image_types[RGBImage] == 2 # RGB images + assert image_types[GrayscaleImage] == 2 # L images + assert image_types[RGBAImage] == 4 # 2 RGBA + 2 LA converted to RGBA From 17e1ee5e8ab7915e7b4cf350c3b866929c131a38 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Mon, 17 Feb 2025 16:43:05 -0600 Subject: [PATCH 6/9] docs --- docs/user_guide/image.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/user_guide/image.rst b/docs/user_guide/image.rst index 95139fd5f..f4bd3f58b 100644 --- a/docs/user_guide/image.rst +++ b/docs/user_guide/image.rst @@ -1,10 +1,10 @@ Image Interface -============== +=============== The ImageInterface allows you to convert various image formats (PNG, JPG, TIFF) to NWB. It supports different color modes and efficiently handles image loading to minimize memory usage. Supported Image Modes -------------------- +--------------------- The interface supports the following PIL image modes: @@ -14,7 +14,7 @@ The interface supports the following PIL image modes: - LA (luminance + alpha) → RGBAImage (automatically converted) Example Usage ------------- +------------- Here's an example demonstrating how to use the ImageInterface with different image modes: @@ -89,7 +89,7 @@ Here's an example demonstrating how to use the ImageInterface with different ima print(f"Image shape: {image.data.shape}") Key Features ------------ +------------ 1. **Memory Efficiency**: Uses an iterator pattern to load images only when needed, making it suitable for large images or multiple images. @@ -109,7 +109,7 @@ Key Features interface = ImageInterface(file_paths=["image.png"], images_location="stimulus") Installation ------------ +------------ To use the ImageInterface, install neuroconv with the image extra: From 4941763aa5de4df25bbf912fdcff6d0260859a5c Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Mon, 17 Feb 2025 16:43:22 -0600 Subject: [PATCH 7/9] remove images --- src/neuroconv/datainterfaces/image/imageinterface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/neuroconv/datainterfaces/image/imageinterface.py b/src/neuroconv/datainterfaces/image/imageinterface.py index 06f864037..711520f63 100644 --- a/src/neuroconv/datainterfaces/image/imageinterface.py +++ b/src/neuroconv/datainterfaces/image/imageinterface.py @@ -213,7 +213,7 @@ def get_metadata(self) -> DeepDict: metadata = super().get_metadata() # Add basic metadata about the images - metadata["Images"] = dict(description="Images loaded through ImageInterface", num_images=len(self.file_paths)) + metadata["Images"] = dict(description="Images loaded through ImageInterface") return metadata From 78f810eab0016cd43656d79dd2c92726577d4f61 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Mon, 17 Feb 2025 21:02:05 -0600 Subject: [PATCH 8/9] small improvements --- pyproject.toml | 2 +- .../datainterfaces/image/imageinterface.py | 36 +++++++++---------- tests/test_image/test_image_interface.py | 24 +++++++------ 3 files changed, 32 insertions(+), 30 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 653512f95..bc09f2acb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -273,7 +273,7 @@ icephys = [ ## Image image = [ - "pillow>=10.0.0", # PIL + "pillow>=10.0.0", ] ## Ophys diff --git a/src/neuroconv/datainterfaces/image/imageinterface.py b/src/neuroconv/datainterfaces/image/imageinterface.py index 711520f63..1fe7b10b1 100644 --- a/src/neuroconv/datainterfaces/image/imageinterface.py +++ b/src/neuroconv/datainterfaces/image/imageinterface.py @@ -5,7 +5,6 @@ import numpy as np from hdmf.data_utils import AbstractDataChunkIterator, DataChunk -from PIL import Image from pynwb import NWBFile from pynwb.base import Images from pynwb.image import GrayscaleImage, RGBAImage, RGBImage @@ -20,6 +19,7 @@ class SingleImageIterator(AbstractDataChunkIterator): def __init__(self, file_path: Union[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: @@ -70,6 +70,8 @@ def __iter__(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)) @@ -105,8 +107,8 @@ def __len__(self): return self._image_shape[0] @property - def size_info(self): - """Return dictionary with size information""" + def image_info(self): + """Return dictionary with image information""" return { "file_size_bytes": self._size_bytes, "memory_size_bytes": self._memory_size, @@ -121,11 +123,11 @@ class ImageInterface(BaseDataInterface): display_name = "Image Interface" keywords = ("image",) - associated_suffixes = (".png", ".jpg", ".jpeg", ".tiff", ".tif") + 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 - MODE_MAPPING = { + IMAGE_MODE_TO_NWB_TYPE_MAP = { "L": GrayscaleImage, "RGB": RGBImage, "RGBA": RGBAImage, @@ -182,14 +184,17 @@ def __init__( self.images_location = images_location super().__init__( - verbose=verbose, file_paths=file_paths, folder_path=folder_path, images_location=images_location + verbose=verbose, + file_paths=file_paths, + folder_path=folder_path, + images_location=images_location, ) # Process paths if folder_path is not None: folder = Path(folder_path) if not folder.exists(): - raise ValueError(f"Folder {folder} does not exist") + raise ValueError(f"Folder path {folder} does not exist") # Get all image files in folder file_paths = [] @@ -199,14 +204,7 @@ def __init__( if not file_paths: raise ValueError(f"No image files found in {folder}") - self.file_paths = [str(Path(p).absolute()) for p in file_paths] - else: - self.file_paths = [str(Path(p).absolute()) for p in file_paths] - - # Validate paths - for path in self.file_paths: - if not Path(path).exists(): - raise ValueError(f"File {path} does not exist") + self.file_paths = [Path(p).resolve() for p in file_paths] def get_metadata(self) -> DeepDict: """Get metadata for the images.""" @@ -220,7 +218,7 @@ def get_metadata(self) -> DeepDict: def add_to_nwbfile( self, nwbfile: NWBFile, - metadata: Optional[dict] = None, + metadata: Optional[DeepDict] = None, container_name: str = "images", ) -> None: """ @@ -254,10 +252,10 @@ def add_to_nwbfile( # Validate mode and get image class if iterator.image_mode not in self.MODE_MAPPING: - raise ValueError(f"Unsupported image mode: {iterator.image_mode}") + raise ValueError(f"Unsupported image mode: {iterator.image_mode} for image {file_path.name}") - image_class = self.MODE_MAPPING[iterator.image_mode] - image_container = image_class(name=image_name, data=iterator) + nwb_image_class = self.IMAGE_MODE_TO_NWB_TYPE_MAP[iterator.image_mode] + image_container = nwb_image_class(name=image_name, data=iterator) # Add to images container images_container.add_image(image_container) diff --git a/tests/test_image/test_image_interface.py b/tests/test_image/test_image_interface.py index 4f1eced98..b847cff37 100644 --- a/tests/test_image/test_image_interface.py +++ b/tests/test_image/test_image_interface.py @@ -97,12 +97,13 @@ class TestRGBImageInterface(DataInterfaceTestMixin): """Test suite for ImageInterface with RGB images.""" data_interface_cls = ImageInterface + mode = "RGB" @pytest.fixture(autouse=True) def make_interface(self, tmp_path, format): """Create interface with RGB test images.""" # Generate test RGB images - generate_random_images(num_images=5, mode="RGB", output_dir_path=tmp_path, format=format) + generate_random_images(num_images=5, mode=self.mode, output_dir_path=tmp_path, format=format) self.interface_kwargs = dict(folder_path=tmp_path) self.interface = self.data_interface_cls(**self.interface_kwargs) @@ -125,12 +126,13 @@ class TestGrayscaleImageInterface(DataInterfaceTestMixin): """Test suite for ImageInterface with grayscale (mode L) images.""" data_interface_cls = ImageInterface + mode = "L" @pytest.fixture(autouse=True) def make_interface(self, tmp_path, format): """Create interface with grayscale test images.""" # Generate test grayscale images - generate_random_images(num_images=5, mode="L", output_dir_path=tmp_path, format=format) + generate_random_images(num_images=5, mode=self.mode, output_dir_path=tmp_path, format=format) self.interface_kwargs = dict(folder_path=tmp_path) self.interface = self.data_interface_cls(**self.interface_kwargs) @@ -153,12 +155,13 @@ class TestRGBAImageInterface(DataInterfaceTestMixin): """Test suite for ImageInterface with RGBA images.""" data_interface_cls = ImageInterface + mode = "RGBA" @pytest.fixture(autouse=True) def make_interface(self, tmp_path, format): """Create interface with RGBA test images.""" # Generate test RGBA images - generate_random_images(num_images=5, mode="RGBA", output_dir_path=tmp_path, format=format) + generate_random_images(num_images=5, mode=self.mode, output_dir_path=tmp_path, format=format) self.interface_kwargs = dict(folder_path=tmp_path) self.interface = self.data_interface_cls(**self.interface_kwargs) @@ -181,12 +184,13 @@ class TestLAtoRGBAImageInterface(DataInterfaceTestMixin): """Test suite for ImageInterface with LA images being converted to RGBA.""" data_interface_cls = ImageInterface + mode = "LA" @pytest.fixture(autouse=True) def make_interface(self, tmp_path, format): """Create interface with LA test images.""" # Generate test LA images - generate_random_images(num_images=5, mode="LA", output_dir_path=tmp_path, format=format) + generate_random_images(num_images=5, mode=self.mode, output_dir_path=tmp_path, format=format) self.interface_kwargs = dict(folder_path=tmp_path) self.interface = self.data_interface_cls(**self.interface_kwargs) @@ -209,7 +213,7 @@ def check_read_nwb(self, nwbfile_path): assert np.all(image.data[..., 1] == image.data[..., 2]) -class TestMixedImagesInterface(DataInterfaceTestMixin): +class TestMixedModeAndFormatImageInterface(DataInterfaceTestMixin): """Test suite for ImageInterface with mixed image modes and formats.""" data_interface_cls = ImageInterface @@ -250,16 +254,16 @@ def check_read_nwb(self, nwbfile_path): assert len(images_container.images) == 8 # Count instances of each image type - image_types = { + num_image_types = { RGBImage: 0, GrayscaleImage: 0, RGBAImage: 0, # This will include both RGBA and converted LA images } for image in images_container.images.values(): - image_types[type(image)] += 1 + num_image_types[type(image)] += 1 # Verify we have the expected number of each type - assert image_types[RGBImage] == 2 # RGB images - assert image_types[GrayscaleImage] == 2 # L images - assert image_types[RGBAImage] == 4 # 2 RGBA + 2 LA converted to RGBA + assert num_image_types[RGBImage] == 2 # RGB images + assert num_image_types[GrayscaleImage] == 2 # L images + assert num_image_types[RGBAImage] == 4 # 2 RGBA + 2 LA converted to RGBA From d087e155df8c6e56be0e8af1915c829444084ae1 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Mon, 17 Feb 2025 21:33:56 -0600 Subject: [PATCH 9/9] move doc to conversion gallery --- .../imaging/image.rst | 92 ++++++++++++++ docs/conversion_examples_gallery/index.rst | 8 ++ docs/user_guide/image.rst | 118 ------------------ docs/user_guide/index.rst | 1 - .../datainterfaces/image/imageinterface.py | 2 +- 5 files changed, 101 insertions(+), 120 deletions(-) create mode 100644 docs/conversion_examples_gallery/imaging/image.rst delete mode 100644 docs/user_guide/image.rst diff --git a/docs/conversion_examples_gallery/imaging/image.rst b/docs/conversion_examples_gallery/imaging/image.rst new file mode 100644 index 000000000..89f427ebc --- /dev/null +++ b/docs/conversion_examples_gallery/imaging/image.rst @@ -0,0 +1,92 @@ +Image data conversion +-------------------- + +The :py:class:`~neuroconv.datainterfaces.image.imageinterface.ImageInterface` allows conversion of various image formats (PNG, JPG, TIFF) to NWB. The interface efficiently handles different color modes and can store images in either the acquisition or stimulus group of the NWB file. + +Install NeuroConv with the additional dependencies necessary for reading image data: + +.. code-block:: bash + + pip install "neuroconv[image]" + +Supported Image Modes +~~~~~~~~~~~~~~~~~~~~ + +The interface automatically converts the following PIL image modes to their corresponding NWB types: + +- L (grayscale) → GrayscaleImage +- RGB → RGBImage +- RGBA → RGBAImage +- LA (luminance + alpha) → RGBAImage (automatically converted) + +Example Usage +~~~~~~~~~~~~ + +.. code-block:: python + + >>> from datetime import datetime + >>> from pathlib import Path + >>> from zoneinfo import ZoneInfo + >>> from neuroconv.datainterfaces import ImageInterface + >>> from pynwb import NWBHDF5IO, NWBFile + >>> + >>> # Create example images of different modes + >>> from PIL import Image + >>> import numpy as np + >>> + >>> # Create a temporary directory for our example images + >>> from tempfile import mkdtemp + >>> image_dir = Path(mkdtemp()) + >>> + >>> # Create example images + >>> # RGB image (3 channels) + >>> rgb_array = np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8) + >>> rgb_image = Image.fromarray(rgb_array, mode='RGB') + >>> rgb_image.save(image_dir / 'rgb_image.png') + >>> + >>> # Grayscale image (L mode) + >>> gray_array = np.random.randint(0, 255, (100, 100), dtype=np.uint8) + >>> gray_image = Image.fromarray(gray_array, mode='L') + >>> gray_image.save(image_dir / 'gray_image.png') + >>> + >>> # RGBA image (4 channels) + >>> rgba_array = np.random.randint(0, 255, (100, 100, 4), dtype=np.uint8) + >>> rgba_image = Image.fromarray(rgba_array, mode='RGBA') + >>> rgba_image.save(image_dir / 'rgba_image.png') + >>> + >>> # LA image (luminance + alpha) + >>> la_array = np.random.randint(0, 255, (100, 100, 2), dtype=np.uint8) + >>> la_image = Image.fromarray(la_array, mode='LA') + >>> la_image.save(image_dir / 'la_image.png') + >>> + >>> # Initialize the image interface + >>> interface = ImageInterface(folder_path=str(image_dir)) + >>> + >>> # Get metadata from the interface + >>> metadata = interface.get_metadata() + >>> session_start_time = datetime(2020, 1, 1, 12, 30, 0, tzinfo=ZoneInfo("US/Pacific")) + >>> metadata["NWBFile"].update(session_start_time=session_start_time) + >>> # Choose a path for saving the nwb file and run the conversion + >>> nwbfile_path = f"{path_to_save_nwbfile}" + >>> interface.run_conversion(nwbfile_path=nwbfile_path, metadata=metadata) + + +Key Features +~~~~~~~~~~~ + +1. **Memory Efficiency**: Uses an iterator pattern to load images only when needed, making it suitable for large images or multiple images. + +2. **Automatic Mode Conversion**: Handles LA (luminance + alpha) to RGBA conversion automatically. + +3. **Input Methods**: + - List of files: ``interface = ImageInterface(file_paths=["image1.png", "image2.jpg"])`` + - Directory: ``interface = ImageInterface(folder_path="images_directory")`` + +4. **Storage Location**: Images can be stored in either acquisition or stimulus: + .. code-block:: python + + # Store in acquisition (default) + interface = ImageInterface(file_paths=["image.png"], images_location="acquisition") + + # Store in stimulus + interface = ImageInterface(file_paths=["image.png"], images_location="stimulus") diff --git a/docs/conversion_examples_gallery/index.rst b/docs/conversion_examples_gallery/index.rst index c12242ad3..35bc2275f 100644 --- a/docs/conversion_examples_gallery/index.rst +++ b/docs/conversion_examples_gallery/index.rst @@ -108,6 +108,14 @@ Behavior MedPC +Image +----- + +.. toctree:: + :maxdepth: 1 + + Image (png, jpeg, tiff, etc) + Text ---- diff --git a/docs/user_guide/image.rst b/docs/user_guide/image.rst deleted file mode 100644 index f4bd3f58b..000000000 --- a/docs/user_guide/image.rst +++ /dev/null @@ -1,118 +0,0 @@ -Image Interface -=============== - -The ImageInterface allows you to convert various image formats (PNG, JPG, TIFF) to NWB. It supports different color modes and efficiently handles image loading to minimize memory usage. - -Supported Image Modes ---------------------- - -The interface supports the following PIL image modes: - -- L (grayscale) → GrayscaleImage -- RGB → RGBImage -- RGBA → RGBAImage -- LA (luminance + alpha) → RGBAImage (automatically converted) - -Example Usage -------------- - -Here's an example demonstrating how to use the ImageInterface with different image modes: - -.. code-block:: python - - from datetime import datetime - from pathlib import Path - from neuroconv.datainterfaces import ImageInterface - from pynwb import NWBHDF5IO, NWBFile - - # Create example images of different modes - from PIL import Image - import numpy as np - - # Create a temporary directory for our example images - from tempfile import mkdtemp - image_dir = Path(mkdtemp()) - - # Create example images - # RGB image (3 channels) - rgb_array = np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8) - rgb_image = Image.fromarray(rgb_array, mode='RGB') - rgb_image.save(image_dir / 'rgb_image.png') - - # Grayscale image (L mode) - gray_array = np.random.randint(0, 255, (100, 100), dtype=np.uint8) - gray_image = Image.fromarray(gray_array, mode='L') - gray_image.save(image_dir / 'gray_image.png') - - # RGBA image (4 channels) - rgba_array = np.random.randint(0, 255, (100, 100, 4), dtype=np.uint8) - rgba_image = Image.fromarray(rgba_array, mode='RGBA') - rgba_image.save(image_dir / 'rgba_image.png') - - # LA image (luminance + alpha) - la_array = np.random.randint(0, 255, (100, 100, 2), dtype=np.uint8) - la_image = Image.fromarray(la_array, mode='LA') - la_image.save(image_dir / 'la_image.png') - - # Initialize the image interface - interface = ImageInterface(folder_path=str(image_dir)) - - # Create a basic NWBFile - nwbfile = NWBFile( - session_description="Image interface example session", - identifier="IMAGE123", - session_start_time=datetime.now().astimezone(), - experimenter="Dr. John Doe", - lab="Image Processing Lab", - institution="Neural Image Institute", - experiment_description="Example experiment demonstrating image conversion", - ) - - # Add the images to the NWB file - interface.add_to_nwbfile(nwbfile) - - # Write the NWB file - nwb_path = Path("image_example.nwb") - with NWBHDF5IO(nwb_path, "w") as io: - io.write(nwbfile) - - # Read the NWB file to verify - with NWBHDF5IO(nwb_path, "r") as io: - nwbfile = io.read() - # Access the images container - images_container = nwbfile.acquisition["images"] - print(f"Number of images: {len(images_container.images)}") - # Print information about each image - for name, image in images_container.images.items(): - print(f"\nImage name: {name}") - print(f"Image type: {type(image).__name__}") - print(f"Image shape: {image.data.shape}") - -Key Features ------------- - -1. **Memory Efficiency**: Uses an iterator pattern to load images only when needed, making it suitable for large images or multiple images. - -2. **Automatic Mode Conversion**: Handles LA (luminance + alpha) to RGBA conversion automatically while maintaining image information. - -3. **Input Methods**: - - List of files: ``interface = ImageInterface(file_paths=["image1.png", "image2.jpg"])`` - - Directory: ``interface = ImageInterface(folder_path="images_directory")`` - -4. **Flexible Storage Location**: Images can be stored in either acquisition or stimulus: - .. code-block:: python - - # Store in acquisition (default) - interface = ImageInterface(file_paths=["image.png"], images_location="acquisition") - - # Store in stimulus - interface = ImageInterface(file_paths=["image.png"], images_location="stimulus") - -Installation ------------- - -To use the ImageInterface, install neuroconv with the image extra: - -.. code-block:: bash - - pip install "neuroconv[image]" diff --git a/docs/user_guide/index.rst b/docs/user_guide/index.rst index 14dbd1aea..bf9aaf253 100644 --- a/docs/user_guide/index.rst +++ b/docs/user_guide/index.rst @@ -19,7 +19,6 @@ and synchronize data across multiple sources. :maxdepth: 2 datainterfaces - image nwbconverter adding_trials temporal_alignment diff --git a/src/neuroconv/datainterfaces/image/imageinterface.py b/src/neuroconv/datainterfaces/image/imageinterface.py index 1fe7b10b1..b7c40673b 100644 --- a/src/neuroconv/datainterfaces/image/imageinterface.py +++ b/src/neuroconv/datainterfaces/image/imageinterface.py @@ -251,7 +251,7 @@ def add_to_nwbfile( image_name = Path(file_path).stem # Validate mode and get image class - if iterator.image_mode not in self.MODE_MAPPING: + 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}") nwb_image_class = self.IMAGE_MODE_TO_NWB_TYPE_MAP[iterator.image_mode]