Repository: NeurodataWithoutBorders/pynwb
Pull request #2079: Add BaseImage and ExternalImage
Author: login: rly
Created: 2025-05-10T07:47:17Z | Merged: 2025-05-15T01:09:50Z

## Motivation

Fix #2042 

## Checklist

- [x] Did you update CHANGELOG.md with your changes?
- [x] Have you checked our [Contributing](https://github.com/NeurodataWithoutBorders/pynwb/blob/dev/docs/CONTRIBUTING.rst) document?
- [x] Have you ensured the PR clearly describes the problem and the solution?
- [x] Is your contribution compliant with our coding style? This can be checked running `ruff check . && codespell` from the source directory.
- [x] Have you checked to ensure that there aren't other open [Pull Requests](https://github.com/NeurodataWithoutBorders/pynwb/pulls) for the same change?
- [x] Have you included the relevant issue number using "Fix #XXX" notation where XXX is the issue number? By including "Fix #XXX" you allow GitHub to close issue #XXX when the PR is merged.


Linked issue #2042: [Feature]: Support ExternalImage for NWB Schema 2.9.0
### What would you like to see added to PyNWB?

See https://github.com/NeurodataWithoutBorders/nwb-schema/pull/604

### Is your feature request related to a problem?

_No response_

### What solution would you like?

Add support for `BaseImage` and `ExternalImage` for NWB Schema 2.9.0

### Do you have any interest in helping implement the feature?

Yes.

### Code of Conduct

- [x] I agree to follow this project's [Code of Conduct](https://github.com/NeurodataWithoutBorders/pynwb/blob/dev/.github/CODE_OF_CONDUCT.rst)
- [x] Have you checked the [Contributing](https://github.com/NeurodataWithoutBorders/pynwb/blob/dev/docs/CONTRIBUTING.rst) document?
- [x] Have you ensured this change was not already [requested](https://github.com/NeurodataWithoutBorders/pynwb/issues)?
Linked issue comments: user: login: stephprince
created_at: 2025-06-27T16:48:35Z
body: This was addressed by #2079 which is now merged.
author_association: CONTRIBUTOR

Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 343790d6c..2de2bee06 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,8 @@
 - Removed unused functions `prepend_string` and `_not_parent` in `core.py`, `_not_parent` in `file.py`, and `NWBBaseTypeMapper.get_nwb_file` in `io/core.py` @oruebel [#2036](https://github.com/NeurodataWithoutBorders/pynwb/pull/2036)
 
 ### Enhancements and minor changes
+- Added support for NWB Schema 2.9.0. @rly [#2079](https://github.com/NeurodataWithoutBorders/pynwb/pull/2079)
+  - Added `BaseImage` and `ExternalImage` as new neurodata types. The first so both `Image` and `ExternalImage` can inherit from it. The second to store external images.
 - Automatically add timezone information to timestamps reference time if no timezone information is specified. @stephprince [#2056](https://github.com/NeurodataWithoutBorders/pynwb/pull/2056)
 - Added option to disable typemap caching and updated type map cache location. @stephprince [#2057](https://github.com/NeurodataWithoutBorders/pynwb/pull/2057)
 - Added dictionary-like operations directly on `ProcessingModule` objects (e.g., `len(processing_module)`). @bendichter [#2020](https://github.com/NeurodataWithoutBorders/pynwb/pull/2020)
@@ -27,7 +29,7 @@
    - The behavior of `pynwb.validate(io=...)` now matches the behavior of `pynwb.validate(path=...)`. In previous pynwb versions, `pynwb.validate(io=...)` did not use the cached namespaces during validation. To obtain the same behavior as in previous versions, you can update the function call to `pynwb.validate(io=..., use_cached_namespaces=False)`
    - `pynwb.validate` will return only a list of validation errors instead of a tuple: (list of validation_errors, status code)
    - the `pynwb.validate(path=...)` argument has been added as a replacement for `pynwb.validate(paths=[...])`, which will be deprecated in a future major release [#2024](https://github.com/NeurodataWithoutBorders/pynwb/pull/2024)
-   - The validate module has been renamed to `validation.py`. The validate method can be 
+   - The validate module has been renamed to `validation.py`. The validate method can be
    imported using `import pynwb; pynwb.validate` or `from pynwb import validate`
 
 ### Deprecations
@@ -43,7 +45,7 @@
   - ``ImageSeries.format`` is fixed to 'external' if an external file is provided.
   - ``ImageSeries.bits_per_pixel`` is deprecated.
   - ``ImagingPlane.manifold``, ``ImagingPlane.conversion`` and ``ImagingPlane.unit`` are deprecated. Use ``ImagingPlane.origin_coords`` and ``ImagingPlane.grid_spacing`` instead.
-  - ``IndexSeries.unit`` is fixed to "N\A". 
+  - ``IndexSeries.unit`` is fixed to "N\A".
   - ``IndexSeries.indexed_timeseries`` is deprecated. Use ``IndexSeries.indexed_images`` instead.
 - The following deprecated methods have been removed:
   - ``NWBFile.add_ic_electrode`` is removed. Use ``NWBFile.add_icephys_electrode`` instead.
@@ -54,7 +56,7 @@
 ### Enhancements and minor changes
 - Added `__all__` to modules. @bendichter [#2021](https://github.com/NeurodataWithoutBorders/pynwb/pull/2021)
 - Added `pynwb.read_nwb` convenience method to simplify reading an NWBFile written with any backend @h-mayorquin [#1994](https://github.com/NeurodataWithoutBorders/pynwb/pull/1994)
-- Constrained `hdmf<5` to prevent future compatibility issues. [#2040](https://github.com/NeurodataWithoutBorders/pynwb/pull/2040)   
+- Constrained `hdmf<5` to prevent future compatibility issues. [#2040](https://github.com/NeurodataWithoutBorders/pynwb/pull/2040)
 
 ### Bug fixes
 - Made distance, orientation, and field_of_view optional in OpticalSeries to match schema @bendichter [#2023](https://github.com/NeurodataWithoutBorders/pynwb/pull/2023)
@@ -75,7 +77,7 @@
 - Updated `SpikeEventSeries`, `DecompositionSeries`, and `FilteredEphys` examples. @stephprince [#2012](https://github.com/NeurodataWithoutBorders/pynwb/pull/2012)
 - Replaced deprecated `scipy.misc.face` dataset in the images tutorial with another example. @stephprince [#2016](https://github.com/NeurodataWithoutBorders/pynwb/pull/2016)
 - Removed Allen Brain Observatory example which was unnecessary and difficult to maintain. @rly [#2026](https://github.com/NeurodataWithoutBorders/pynwb/pull/2026)
- 
+
 ## PyNWB 2.8.3 (November 19, 2024)
 
 ### Enhancements and minor changes
diff --git a/src/pynwb/base.py b/src/pynwb/base.py
index cd982e239..f357d0d58 100644
--- a/src/pynwb/base.py
+++ b/src/pynwb/base.py
@@ -1,5 +1,6 @@
 from warnings import warn
 from collections.abc import Iterable
+from abc import ABC
 from typing import NamedTuple
 
 import numpy as np
@@ -15,6 +16,8 @@
 __all__ = [
     'ProcessingModule',
     'TimeSeries',
+    'BaseImage',
+    'ExternalImage',
     'Image',
     'ImageReferences',
     'Images',
@@ -402,7 +405,7 @@ def get_data_in_units(self):
         .. math::
             out = data * conversion + offset
 
-        If the field 'channel_conversion' is present, the conversion factor for each channel is additionally applied 
+        If the field 'channel_conversion' is present, the conversion factor for each channel is additionally applied
         to each channel:
 
         .. math::
@@ -422,26 +425,80 @@ def get_data_in_units(self):
         return np.asarray(self.data) * scale_factor + self.offset
 
 
+@register_class('BaseImage', CORE_NAMESPACE)
+class BaseImage(NWBData, ABC):
+    """
+    An abstract base type for image data. Parent type for Image and ExternalImage types.
+    """
+    __nwbfields__ = ('description', )
+
+    @docval({'name': 'name', 'type': str, 'doc': 'The name of the image'},
+            {'name': 'data', 'type': None, 'doc': 'The image data (any type) as specified by a subclass.'},
+            {'name': 'description', 'type': str, 'doc': 'Description of the image', 'default': None},
+            allow_positional=AllowPositional.WARNING,)
+    def __init__(self, **kwargs):
+        description = kwargs.pop('description')
+        super().__init__(**kwargs)
+
+        self.description = description
+
+
 @register_class('Image', CORE_NAMESPACE)
-class Image(NWBData):
+class Image(BaseImage):
     """
-    Abstract image class. It is recommended to instead use pynwb.image.GrayscaleImage or pynwb.image.RGPImage where
-    appropriate.
+    A base type for storing image data directly. Shape can be 2-D (x, y), or 3-D where the
+    third dimension can have three or four elements, e.g. (x, y, (r, g, b)) or
+    (x, y, (r, g, b, a)).
     """
-    __nwbfields__ = ('data', 'resolution', 'description')
+    __nwbfields__ = ('resolution', )
 
-    @docval({'name': 'name', 'type': str, 'doc': 'The name of this image'},
-            {'name': 'data', 'type': ('array_data', 'data'), 'doc': 'data of image. Dimensions: x, y [, r,g,b[,a]]',
+    @docval(*get_docval(BaseImage.__init__, 'name'),
+            {'name': 'data', 'type': ('array_data', 'data'),
+             'doc': 'Data of image. Shape can be (x, y), (x, y, 3), or (x, y, 4).',
              'shape': ((None, None), (None, None, 3), (None, None, 4))},
             {'name': 'resolution', 'type': float, 'doc': 'pixels / cm', 'default': None},
-            {'name': 'description', 'type': str, 'doc': 'description of image', 'default': None},
+            *get_docval(BaseImage.__init__, 'description'),
             allow_positional=AllowPositional.WARNING,)
     def __init__(self, **kwargs):
-        args_to_set = popargs_to_dict(("resolution", "description"), kwargs)
+        resolution = kwargs.pop('resolution')
         super().__init__(**kwargs)
 
-        for key, val in args_to_set.items():
-            setattr(self, key, val)
+        self.resolution = resolution
+
+
+@register_class('ExternalImage', CORE_NAMESPACE)
+class ExternalImage(BaseImage):
+    """
+    A type for referencing an external image file.
+    """
+    __nwbfields__ = ('image_format', 'image_mode')
+
+    @docval(*get_docval(BaseImage.__init__, 'name'),
+            {'name': 'data', 'type': str, 'doc': 'Path or URL to the external image file.'},
+            *get_docval(BaseImage.__init__, 'description'),
+            {
+                'name': 'image_format',
+                'type': str,
+                'doc': (
+                    'Common name of the image file format. Only widely readable, open file formats are allowed.'
+                    'Allowed values are "PNG", "JPEG", and "GIF".'
+                ),
+                'enum': ['PNG', 'JPEG', 'GIF'],
+            },
+            {
+                'name': 'image_mode',
+                'type': str,
+                'doc': 'Image mode (color mode) of the image, e.g., "RGB", "RGBA", "grayscale", and "LA".',
+                'default': None,
+            },
+            allow_positional=AllowPositional.WARNING,)
+    def __init__(self, **kwargs):
+        image_format = kwargs.pop('image_format')
+        image_mode = kwargs.pop('image_mode')
+        super().__init__(**kwargs)
+
+        self.image_format = image_format
+        self.image_mode = image_mode
 
 
 @register_class('ImageReferences', CORE_NAMESPACE)
@@ -455,11 +512,9 @@ class ImageReferences(NWBData):
             {'name': 'data', 'type': 'array_data', 'doc': 'The images in order.'},
             allow_positional=AllowPositional.WARNING,)
     def __init__(self, **kwargs):
-        # NOTE we do not use the docval shape validator here because it will recognize a list of P MxN images as
-        # having shape (P, M, N)
-        # check type and dimensionality
         for image in kwargs['data']:
-            assert isinstance(image, Image), "Images used in ImageReferences must have type Image, not %s" % type(image)
+            assert isinstance(image, BaseImage), \
+                "Images used in ImageReferences must have type BaseImage, not %s" % type(image)
         super().__init__(**kwargs)
 
 
@@ -476,7 +531,7 @@ class Images(MultiContainerInterface):
     __clsconf__ = {
         'attr': 'images',
         'add': 'add_image',
-        'type': Image,
+        'type': BaseImage,
         'get': 'get_image',
         'create': 'create_image'
     }
@@ -485,7 +540,7 @@ class Images(MultiContainerInterface):
             {'name': 'images', 'type': 'array_data', 'doc': 'image objects', 'default': None},
             {'name': 'description', 'type': str, 'doc': 'description of images', 'default': 'no description'},
             {'name': 'order_of_images', 'type': ImageReferences,
-             'doc': 'Ordered dataset of references to Image objects stored in the parent group.', 'default': None},
+             'doc': 'Ordered dataset of references to BaseImage objects stored in the parent group.', 'default': None},
              allow_positional=AllowPositional.WARNING,)
     def __init__(self, **kwargs):
 
diff --git a/src/pynwb/io/base.py b/src/pynwb/io/base.py
index db9c259ef..409c98d64 100644
--- a/src/pynwb/io/base.py
+++ b/src/pynwb/io/base.py
@@ -2,7 +2,7 @@
 
 from .core import NWBContainerMapper
 from .. import register_map
-from ..base import TimeSeries, ProcessingModule
+from ..base import TimeSeries, ProcessingModule, Images
 
 
 @register_map(ProcessingModule)
@@ -125,3 +125,13 @@ def unit_carg(self, builder, manager):
         if unit_value is None:
             return timeseries_cls.DEFAULT_UNIT
         return unit_value
+
+
+@register_map(Images)
+class ImagesMap(NWBContainerMapper):
+
+    def __init__(self, spec):
+        super().__init__(spec)
+
+        # Map the images attribute to the BaseImage neurodata type
+        self.map_spec('images', spec.get_neurodata_type('BaseImage'))
diff --git a/src/pynwb/nwb-schema b/src/pynwb/nwb-schema
index 54f4980e3..3616ed183 160000
--- a/src/pynwb/nwb-schema
+++ b/src/pynwb/nwb-schema
@@ -1 +1 @@
-Subproject commit 54f4980e3be54f2c05582371d25b76074e160d40
+Subproject commit 3616ed183c38d55e9cca3021d65633d6a8656846
diff --git a/tests/integration/hdf5/test_base.py b/tests/integration/hdf5/test_base.py
index 60f8510ff..3e8238b61 100644
--- a/tests/integration/hdf5/test_base.py
+++ b/tests/integration/hdf5/test_base.py
@@ -3,7 +3,7 @@
 from dateutil.tz import tzlocal
 
 from pynwb import TimeSeries, NWBFile, NWBHDF5IO
-from pynwb.base import Images, Image, ImageReferences
+from pynwb.base import Images, Image, ImageReferences, ExternalImage
 from pynwb.testing import AcquisitionH5IOMixin, TestCase, remove_test_file
 
 
@@ -78,3 +78,54 @@ def setUpContainer(self):
         images = Images(name='images_name', images=[image1, image2], order_of_images=image_references)
 
         return images
+
+
+class TestExternalImageInImagesIO(AcquisitionH5IOMixin, TestCase):
+
+    def setUpContainer(self):
+        """ Return the test Images with ExternalImage to read/write """
+        ext_img1 = ExternalImage(name='test_external_image1', data='path/to/image1.jpg', image_format='JPEG')
+        ext_img2 = ExternalImage(name='test_external_image2.jpg', data='path/to/image2.jpg',
+                                description='An external image', image_format="JPEG", image_mode='RGB')
+
+        # Create an Images container with the ExternalImage objects
+        images = Images(name='test_images', images=[ext_img1, ext_img2])
+
+        return images
+
+
+class TestExternalImageWithReferencesIO(AcquisitionH5IOMixin, TestCase):
+
+    def setUpContainer(self):
+        """ Return the test Images with ExternalImage and ImageReferences to read/write """
+        ext_img1 = ExternalImage(name="test_external_image1", data="path/to/image1.jpg", image_format="JPEG")
+        ext_img2 = ExternalImage(name="test_external_image2", data="path/to/image2.png", image_format="PNG")
+        ext_img3 = ExternalImage(name="test_external_image3", data="path/to/image3.gif", image_format="GIF")
+
+        # Create ImageReferences with the ExternalImage objects
+        image_references = ImageReferences(name="order_of_images", data=[ext_img3, ext_img2, ext_img1])
+
+        # Create an Images container with the ExternalImage objects and ImageReferences
+        images = Images(name="test_images", images=[ext_img1, ext_img2, ext_img3], order_of_images=image_references)
+
+        return images
+
+
+class TestMixedImagesIO(AcquisitionH5IOMixin, TestCase):
+    """Test reading and writing Images container with both Image and ExternalImage objects."""
+
+    def setUpContainer(self):
+        """Return the test Images with both Image and ExternalImage to read/write."""
+        # Create a regular Image
+        image1 = Image(name='test_image', data=np.ones((10, 10)))
+
+        # Create an ExternalImage
+        ext_img = ExternalImage(name='test_external_image', data='path/to/image.jpg', image_format='JPEG')
+
+        # Create ImageReferences with both types of images
+        image_references = ImageReferences(name='order_of_images', data=[ext_img, image1])
+
+        # Create an Images container with both types of images and ImageReferences
+        images = Images(name='mixed_images', images=[image1, ext_img], order_of_images=image_references)
+
+        return images
diff --git a/tests/unit/test_external_image.py b/tests/unit/test_external_image.py
new file mode 100644
index 000000000..0b11d3306
--- /dev/null
+++ b/tests/unit/test_external_image.py
@@ -0,0 +1,74 @@
+from pynwb.base import ExternalImage, Images, ImageReferences
+from pynwb.testing import TestCase
+
+
+class TestExternalImage(TestCase):
+    """Test the ExternalImage class."""
+
+    def test_init(self):
+        """Test creating an ExternalImage."""
+        file_path = "path/to/image.jpg"
+        ext_img = ExternalImage(name="test_external_image", data=file_path, image_format="JPEG")
+
+        self.assertEqual(ext_img.name, "test_external_image")
+        self.assertEqual(ext_img.data, file_path)
+        self.assertEqual(ext_img.image_format, "JPEG")
+        self.assertIsNone(ext_img.description)
+
+    def test_init_with_fields(self):
+        """Test creating an ExternalImage with a description, image format, and image mode."""
+        file_path = "path/to/image.jpg"
+        description = "An external image"
+        ext_img = ExternalImage(name="test_external_image", data=file_path, description=description,
+                                image_format="JPEG", image_mode='RGB')
+
+        self.assertEqual(ext_img.name, "test_external_image")
+        self.assertEqual(ext_img.data, file_path)
+        self.assertEqual(ext_img.description, description)
+        self.assertEqual(ext_img.image_format, "JPEG")
+        self.assertEqual(ext_img.image_mode, 'RGB')
+
+    def test_init_invalid_image_format(self):
+        with self.assertRaises(ValueError):
+            ExternalImage(name="test_external_image", data="path/to/image.jpg", image_format="INVALID_FORMAT")
+
+    def test_url_as_file_path(self):
+        """Test creating an ExternalImage with a URL as the file path."""
+        file_path = "https://example.com/image.jpg"
+        ext_img = ExternalImage(name="test_external_image", data=file_path, image_format="JPEG")
+
+        self.assertEqual(ext_img.name, "test_external_image")
+        self.assertEqual(ext_img.data, file_path)
+
+    def test_in_images_container(self):
+        """Test adding an ExternalImage to an Images container."""
+        ext_img1 = ExternalImage(name="test_external_image1", data="path/to/image1.jpg", image_format="JPEG")
+        ext_img2 = ExternalImage(name="test_external_image2", data="path/to/image2.png", image_format="PNG")
+        ext_img3 = ExternalImage(name="test_external_image3", data="path/to/image3.gif", image_format="GIF")
+
+        # Create an Images container with the ExternalImage objects
+        images = Images(name="test_images", images=[ext_img1, ext_img2, ext_img3])
+
+        # Check that the ExternalImage objects are in the Images container
+        self.assertIn("test_external_image1", images.images)
+        self.assertIn("test_external_image2", images.images)
+        self.assertIs(images.images["test_external_image1"], ext_img1)
+        self.assertIs(images.images["test_external_image2"], ext_img2)
+        self.assertIs(images.images["test_external_image3"], ext_img3)
+
+    def test_with_image_references(self):
+        """Test using ExternalImage with ImageReferences."""
+        ext_img1 = ExternalImage(name="test_external_image1", data="path/to/image1.jpg", image_format="JPEG")
+        ext_img2 = ExternalImage(name="test_external_image2", data="path/to/image2.png", image_format="PNG")
+        ext_img3 = ExternalImage(name="test_external_image3", data="path/to/image3.gif", image_format="GIF")
+
+        # Create ImageReferences with the ExternalImage objects
+        image_references = ImageReferences(name="order_of_images", data=[ext_img3, ext_img2, ext_img1])
+
+        # Create an Images container with the ExternalImage objects and ImageReferences
+        images = Images(name="test_images", images=[ext_img1, ext_img2, ext_img3], order_of_images=image_references)
+
+        # Check that the order in ImageReferences is correct
+        self.assertIs(images.order_of_images[0], ext_img3)
+        self.assertIs(images.order_of_images[1], ext_img2)
+        self.assertIs(images.order_of_images[2], ext_img1)


Reviews:
user: login: stephprince
body: This looks good to me but I think I am not following when something should inherit from `BaseImage`, `Image`, or `ExternalImage`. Should `RGBImage`, `GrayscaleImage` and `RGBAImage` also inherit from `BaseImage` now so that they can support `ExternalImage`?
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T17:51:35Z
user: login: rly
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T23:21:12Z
user: login: rly
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T02:53:30Z
user: login: stephprince
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T23:05:30Z

Review comments:
diff_hunk: @@ -422,26 +423,59 @@ def get_data_in_units(self):
         return np.asarray(self.data) * scale_factor + self.offset
 
 
+@register_class('BaseImage', CORE_NAMESPACE)
+class BaseImage(NWBData, ABC):
+    """
+    An abstract base type for image data. Parent type for Image and ExternalImage types.
+    """
+    __nwbfields__ = ('description', )
path: src/pynwb/base.py
user: login: stephprince
body: ```suggestion
    __nwbfields__ = ('data', 'description', )
```
created_at: 2025-05-12T16:36:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -125,3 +125,13 @@ def unit_carg(self, builder, manager):
         if unit_value is None:
             return timeseries_cls.DEFAULT_UNIT
         return unit_value
+
+
+@register_map(Images)
+class ImagesMap(NWBContainerMapper):
+
+    def __init__(self, spec):
+        super().__init__(spec)
+
+        # Map the images attribute to the BaseImage neurodata type
+        self.map_spec('images', spec.get_neurodata_type('BaseImage'))
path: src/pynwb/io/base.py
user: login: stephprince
body: Do we need tests for the mapper? These lines are showing up as not covered by codecov
created_at: 2025-05-12T16:45:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -422,26 +423,59 @@ def get_data_in_units(self):
         return np.asarray(self.data) * scale_factor + self.offset
 
 
+@register_class('BaseImage', CORE_NAMESPACE)
+class BaseImage(NWBData, ABC):
+    """
+    An abstract base type for image data. Parent type for Image and ExternalImage types.
+    """
+    __nwbfields__ = ('description', )
path: src/pynwb/base.py
user: login: rly
body: "data" should not have been a nwbfile in Image in the first place and should not be part of BaseImage as well. All subtypes of Data already have an internal field named `__data` that does not go through the `__nwbfields__` mechanism for generating getters and setters.
created_at: 2025-05-13T23:21:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -125,3 +125,13 @@ def unit_carg(self, builder, manager):
         if unit_value is None:
             return timeseries_cls.DEFAULT_UNIT
         return unit_value
+
+
+@register_map(Images)
+class ImagesMap(NWBContainerMapper):
+
+    def __init__(self, spec):
+        super().__init__(spec)
+
+        # Map the images attribute to the BaseImage neurodata type
+        self.map_spec('images', spec.get_neurodata_type('BaseImage'))
path: src/pynwb/io/base.py
user: login: rly
body: I think codecov screwed up with this flag in the PR files view. https://app.codecov.io/gh/NeurodataWithoutBorders/pynwb/pull/2079?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=NeurodataWithoutBorders shows the line being covered and if I add a breakpoint in this function and run the tests locally, I hit the breakpoint with `pytest tests/integration/hdf5/test_base.py::TestImagesIO`
created_at: 2025-05-14T02:53:30Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov[bot]
created_at: 2025-05-10T07:52:13Z
body: ## [Codecov](https://app.codecov.io/gh/NeurodataWithoutBorders/pynwb/pull/2079?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=NeurodataWithoutBorders) Report
:white_check_mark: All modified and coverable lines are covered by tests.
:white_check_mark: Project coverage is 94.87%. Comparing base ([`f956fbb`](https://app.codecov.io/gh/NeurodataWithoutBorders/pynwb/commit/f956fbb30fdf361a9ae43a71fa509218012adb46?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=NeurodataWithoutBorders)) to head ([`ed36103`](https://app.codecov.io/gh/NeurodataWithoutBorders/pynwb/commit/ed36103cd4b8927eb89f537af87dc101f408fdc4?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=NeurodataWithoutBorders)).

<details><summary>Additional details and impacted files</summary>



```diff
@@                 Coverage Diff                  @@
##           nwb-schema-2.9.0    #2079      +/-   ##
====================================================
+ Coverage             94.83%   94.87%   +0.04%     
====================================================
  Files                    28       28              
  Lines                  2749     2772      +23     
  Branches                708      712       +4     
====================================================
+ Hits                   2607     2630      +23     
  Misses                   82       82              
  Partials                 60       60              
```

| [Flag](https://app.codecov.io/gh/NeurodataWithoutBorders/pynwb/pull/2079/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=NeurodataWithoutBorders) | Coverage Δ | |
|---|---|---|
| [integration](https://app.codecov.io/gh/NeurodataWithoutBorders/pynwb/pull/2079/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=NeurodataWithoutBorders) | `74.38% <100.00%> (+0.21%)` | :arrow_up: |
| [unit](https://app.codecov.io/gh/NeurodataWithoutBorders/pynwb/pull/2079/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=NeurodataWithoutBorders) | `84.88% <93.54%> (+0.05%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=NeurodataWithoutBorders#carryforward-flags-in-the-pull-request-comment) to find out more.
</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/NeurodataWithoutBorders/pynwb/pull/2079?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=NeurodataWithoutBorders).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=NeurodataWithoutBorders).
<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: rly
created_at: 2025-05-14T03:22:29Z
body: We discussed this a bit in person. To restate and add to our discussion: `RGBImage` already exists and represents RGB data stored as a 3-dimensional array in some backend. `ExternalImage` represents an image stored as a file path. It does not describe what kind of image the data is. We could create an `ExternalRGBImage` and other subclasses, but those would not have any differentiating specifications. Contrary to what we discussed, I now think it would be valuable to hint to the user what kind of data the image is. The image file itself would contain much more information about it, e.g., bit depth, gamma correction, dimensions.

This is similar to the question of how to differentiate types of `ElectricalSeries`, e.g., wideband, LFP, AP, non-neural, that all have the same structure. I believe we decided at a recent meeting that it would be better to add an enumerated text field that indicates what type of data this is than to create a subclass for each possible type. I am still torn on that decision, but I think both are fine and we should go based on consensus.

I realize there are some parts of this [proposal](https://github.com/NeurodataWithoutBorders/nwb-schema/issues/603#issuecomment-2616464570) that are not in the schema. I'll create a PR on nwb-schema to address them and we can come back to here.
author_association: CONTRIBUTOR
user: login: stephprince
created_at: 2025-05-14T17:09:39Z
body: > This is similar to the question of how to differentiate types of ElectricalSeries, e.g., wideband, LFP, AP, non-neural, that all have the same structure. I believe we decided at a recent meeting that it would be better to add an enumerated text field that indicates what type of data this is than to create a subclass for each possible type. I am still torn on that decision, but I think both are fine and we should go based on consensus.

I am also torn on that decision still... (just linking for future reference the context of the previous discussion was for this issue: https://github.com/NeurodataWithoutBorders/nwb-schema/issues/617). I think in this case it is slightly awkward that RGBImage, GrayscaleImage, etc. are distinct types in one part of the schema but then will be only distinguished by the `image_mode` attribute in other parts of the schema... I am not opposed to the enumerated text field but am just wondering if the circumstances are different here.

Edit: After rereading the discussion in the related nwb-schema PR I see the advantage of an optional `image_mode` attribute to support different file types beyond the current Image data types in the schema.
author_association: CONTRIBUTOR
user: login: h-mayorquin
created_at: 2025-05-21T18:51:57Z
body: To add to this discussion. One of the reasons for ExternalImage was that it would be avoid the proliferation of data types and therefore reduce our dev burden.

See simplification of data types section here:
https://github.com/NeurodataWithoutBorders/nwb-schema/issues/603
author_association: CONTRIBUTOR