diff --git a/CHANGELOG.md b/CHANGELOG.md index 4832ab303..98a771273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ change, the impact user codes should be minimal as this change primarily adds functionality while the overall behavior of the API is largely consistent with existing behavior. @oruebel, @rly (#1390) +# Enhancements +- Added support for NWB 2.5.0. + - Added support for updated ``IndexSeries`` type, new ``order_of_images`` field in ``Images``, and new neurodata_type + ``ImageReferences``. @rly (#1483) + ### Documentation and tutorial enhancements: - Added tutorial on annotating data via ``TimeIntervals``. @oruebel (#1390) - Add copy button to code blocks @weiglszonja (#1460) diff --git a/src/pynwb/base.py b/src/pynwb/base.py index 18b8c42a9..625b4fe98 100644 --- a/src/pynwb/base.py +++ b/src/pynwb/base.py @@ -105,15 +105,21 @@ class TimeSeries(NWBDataInterface): DEFAULT_DATA = np.ndarray(shape=(0, ), dtype=np.uint8) DEFAULT_UNIT = 'unknown' + DEFAULT_RESOLUTION = -1.0 + DEFAULT_CONVERSION = 1.0 + DEFAULT_OFFSET = 0.0 + @docval({'name': 'name', 'type': str, 'doc': 'The name of this TimeSeries dataset'}, # required {'name': 'data', 'type': ('array_data', 'data', 'TimeSeries'), 'doc': ('The data values. The first dimension must be time. ' 'Can also store binary data, e.g., image frames')}, {'name': 'unit', 'type': str, 'doc': 'The base unit of measurement (should be SI unit)'}, {'name': 'resolution', 'type': 'float', - 'doc': 'The smallest meaningful difference (in specified unit) between values in data', 'default': -1.0}, + 'doc': 'The smallest meaningful difference (in specified unit) between values in data', + 'default': DEFAULT_RESOLUTION}, {'name': 'conversion', 'type': 'float', - 'doc': 'Scalar to multiply each element in data to convert it to the specified unit', 'default': 1.0}, + 'doc': 'Scalar to multiply each element in data to convert it to the specified unit', + 'default': DEFAULT_CONVERSION}, { 'name': 'offset', 'type': 'float', @@ -121,7 +127,7 @@ class TimeSeries(NWBDataInterface): "Scalar to add to each element in the data scaled by 'conversion' to finish converting it to the " "specified unit." ), - 'default': 0.0 + 'default': DEFAULT_OFFSET }, {'name': 'timestamps', 'type': ('array_data', 'data', 'TimeSeries'), 'shape': (None,), 'doc': 'Timestamps for samples stored in data', 'default': None}, @@ -295,10 +301,33 @@ def __init__(self, **kwargs): self.description = kwargs['description'] +@register_class('ImageReferences', CORE_NAMESPACE) +class ImageReferences(NWBData): + """ + Ordered dataset of references to Image objects. + """ + __nwbfields__ = ('data', ) + + @docval({'name': 'name', 'type': str, 'doc': 'The name of this ImageReferences object.'}, + {'name': 'data', 'type': 'array_data', 'doc': 'The images in order.'},) + 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) + super().__init__(**kwargs) + + @register_class('Images', CORE_NAMESPACE) class Images(MultiContainerInterface): + """An collection of images with an optional way to specify the order of the images + using the "order_of_images" dataset. An order must be specified if the images are + referenced by index, e.g., from an IndexSeries. + """ - __nwbfields__ = ('description',) + __nwbfields__ = ('description', + {'name': 'order_of_images', 'child': True, 'required_name': 'order_of_images'}) __clsconf__ = { 'attr': 'images', @@ -310,12 +339,15 @@ class Images(MultiContainerInterface): @docval({'name': 'name', 'type': str, 'doc': 'The name of this set of images'}, {'name': 'images', 'type': 'array_data', 'doc': 'image objects', 'default': None}, - {'name': 'description', 'type': str, 'doc': 'description of images', 'default': 'no description'}) + {'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},) def __init__(self, **kwargs): - name, description, images = popargs('name', 'description', 'images', kwargs) + name, description, images, order_of_images = popargs('name', 'description', 'images', 'order_of_images', kwargs) super(Images, self).__init__(name, **kwargs) self.description = description self.images = images + self.order_of_images = order_of_images class TimeSeriesReference(NamedTuple): diff --git a/src/pynwb/image.py b/src/pynwb/image.py index 67d596b45..4e0377630 100644 --- a/src/pynwb/image.py +++ b/src/pynwb/image.py @@ -5,7 +5,7 @@ from hdmf.utils import docval, getargs, popargs, call_docval_func, get_docval from . import register_class, CORE_NAMESPACE -from .base import TimeSeries, Image +from .base import TimeSeries, Image, Images from .device import Device @@ -115,13 +115,36 @@ class IndexSeries(TimeSeries): 'doc': ('The data values. Must be 1D, where the first dimension must be time (frame)')}, *get_docval(TimeSeries.__init__, 'unit'), # required {'name': 'indexed_timeseries', 'type': TimeSeries, # required - 'doc': 'HDF5 link to TimeSeries containing images that are indexed.'}, + 'doc': 'Link to TimeSeries containing images that are indexed.', 'default': None}, + {'name': 'indexed_images', 'type': Images, # required + 'doc': ("Link to Images object containing an ordered set of images that are indexed. The Images object " + "must contain a 'ordered_images' dataset specifying the order of the images in the Images type."), + 'default': None}, *get_docval(TimeSeries.__init__, 'resolution', 'conversion', 'timestamps', 'starting_time', 'rate', 'comments', 'description', 'control', 'control_description', 'offset')) def __init__(self, **kwargs): - indexed_timeseries = popargs('indexed_timeseries', kwargs) + indexed_timeseries, indexed_images = popargs('indexed_timeseries', 'indexed_images', kwargs) + if kwargs['unit'] and kwargs['unit'] != 'N/A': + msg = ("The 'unit' field of IndexSeries is fixed to the value 'N/A' starting in NWB 2.5. Passing " + "a different value for 'unit' will raise an error in PyNWB 3.0.") + warnings.warn(msg, PendingDeprecationWarning) + if not indexed_timeseries and not indexed_images: + msg = "Either indexed_timeseries or indexed_images must be provided when creating an IndexSeries." + raise ValueError(msg) + if indexed_timeseries: + msg = ("The indexed_timeseries field of IndexSeries is discouraged and will be deprecated in " + "a future version of NWB. Use the indexed_images field instead.") + warnings.warn(msg, PendingDeprecationWarning) + kwargs['unit'] = 'N/A' # fixed value starting in NWB 2.5 super(IndexSeries, self).__init__(**kwargs) self.indexed_timeseries = indexed_timeseries + self.indexed_images = indexed_images + if kwargs['conversion'] and kwargs['conversion'] != self.DEFAULT_CONVERSION: + warnings.warn("The conversion attribute is not used by IndexSeries.") + if kwargs['resolution'] and kwargs['resolution'] != self.DEFAULT_RESOLUTION: + warnings.warn("The resolution attribute is not used by IndexSeries.") + if kwargs['offset'] and kwargs['offset'] != self.DEFAULT_OFFSET: + warnings.warn("The offset attribute is not used by IndexSeries.") @register_class('ImageMaskSeries', CORE_NAMESPACE) diff --git a/src/pynwb/io/core.py b/src/pynwb/io/core.py index 34c78f823..cba7dbf66 100644 --- a/src/pynwb/io/core.py +++ b/src/pynwb/io/core.py @@ -34,9 +34,9 @@ class NWBDataMap(NWBBaseTypeMapper): def carg_name(self, builder, manager): return builder.name - @ObjectMapper.constructor_arg('data') - def carg_data(self, builder, manager): - return builder.data + # @ObjectMapper.constructor_arg('data') + # def carg_data(self, builder, manager): + # return builder.data @register_map(ScratchData) diff --git a/src/pynwb/io/file.py b/src/pynwb/io/file.py index ccff9c5b6..19c6c384f 100644 --- a/src/pynwb/io/file.py +++ b/src/pynwb/io/file.py @@ -30,6 +30,7 @@ def __init__(self, spec): self.unmap(stimulus_spec.get_group('templates')) self.map_spec('stimulus', stimulus_spec.get_group('presentation').get_neurodata_type('TimeSeries')) self.map_spec('stimulus_template', stimulus_spec.get_group('templates').get_neurodata_type('TimeSeries')) + self.map_spec('stimulus_template', stimulus_spec.get_group('templates').get_neurodata_type('Images')) intervals_spec = self.spec.get_group('intervals') self.unmap(intervals_spec) diff --git a/src/pynwb/nwb-schema b/src/pynwb/nwb-schema index 8ce92e1ae..eec91c62e 160000 --- a/src/pynwb/nwb-schema +++ b/src/pynwb/nwb-schema @@ -1 +1 @@ -Subproject commit 8ce92e1ae69a7b44980e11e88ab0152e655cb0b8 +Subproject commit eec91c62ea801f3c508205a8c9d4b03eb515c59b diff --git a/tests/integration/hdf5/test_base.py b/tests/integration/hdf5/test_base.py index a13ffe7ff..1e855f3ce 100644 --- a/tests/integration/hdf5/test_base.py +++ b/tests/integration/hdf5/test_base.py @@ -3,6 +3,7 @@ from dateutil.tz import tzlocal from pynwb import TimeSeries, NWBFile, NWBHDF5IO +from pynwb.base import Images, Image, ImageReferences from pynwb.testing import AcquisitionH5IOMixin, TestCase, remove_test_file @@ -44,3 +45,15 @@ def test_timestamps_linking(self): tsa = nwbfile.acquisition['a'] tsb = nwbfile.acquisition['b'] self.assertIs(tsa.timestamps, tsb.timestamps) + + +class TestImagesIO(AcquisitionH5IOMixin, TestCase): + + def setUpContainer(self): + """ Return the test Images to read/write """ + image1 = Image(name='test_image', data=np.ones((10, 10))) + image2 = Image(name='test_image2', data=np.ones((10, 10))) + image_references = ImageReferences(name='order_of_images', data=[image2, image1]) + images = Images(name='images_name', images=[image1, image2], order_of_images=image_references) + + return images diff --git a/tests/unit/test_base.py b/tests/unit/test_base.py index 28068d5b3..844f6989d 100644 --- a/tests/unit/test_base.py +++ b/tests/unit/test_base.py @@ -2,7 +2,8 @@ import numpy as np -from pynwb.base import ProcessingModule, TimeSeries, Images, Image, TimeSeriesReferenceVectorData, TimeSeriesReference +from pynwb.base import (ProcessingModule, TimeSeries, Images, Image, TimeSeriesReferenceVectorData, + TimeSeriesReference, ImageReferences) from pynwb.testing import TestCase from hdmf.data_utils import DataChunkIterator from hdmf.backends.hdf5 import H5DataIO @@ -246,9 +247,13 @@ def test_image(self): class TestImages(TestCase): def test_images(self): - image = Image(name='test_image', data=np.ones((10, 10))) + image1 = Image(name='test_image', data=np.ones((10, 10))) image2 = Image(name='test_image2', data=np.ones((10, 10))) - Images(name='images_name', images=[image, image2]) + image_references = ImageReferences(name='order_of_images', data=[image2, image1]) + images = Images(name='images_name', images=[image1, image2], order_of_images=image_references) + + self.assertIs(images.order_of_images[0], image2) + self.assertIs(images.order_of_images[1], image1) class TestTimeSeriesReferenceVectorData(TestCase): diff --git a/tests/unit/test_image.py b/tests/unit/test_image.py index ef6f50cf7..8af641498 100644 --- a/tests/unit/test_image.py +++ b/tests/unit/test_image.py @@ -1,6 +1,7 @@ import numpy as np from pynwb import TimeSeries +from pynwb.base import Image, Images, ImageReferences from pynwb.device import Device from pynwb.image import ImageSeries, IndexSeries, ImageMaskSeries, OpticalSeries, GrayscaleImage, RGBImage, RGBAImage from pynwb.testing import TestCase @@ -76,21 +77,58 @@ def test_external_file_no_unit(self): class IndexSeriesConstructor(TestCase): def test_init(self): + image1 = Image(name='test_image', data=np.ones((10, 10))) + image2 = Image(name='test_image2', data=np.ones((10, 10))) + image_references = ImageReferences(name='order_of_images', data=[image2, image1]) + images = Images(name='images_name', images=[image1, image2], order_of_images=image_references) + + iS = IndexSeries( + name='test_iS', + data=[1, 2, 3], + unit='N/A', + indexed_images=images, + timestamps=[0.1, 0.2, 0.3] + ) + self.assertEqual(iS.name, 'test_iS') + self.assertEqual(iS.unit, 'N/A') + self.assertIs(iS.indexed_images, images) + + def test_init_bad_unit(self): ts = TimeSeries( name='test_ts', data=[1, 2, 3], unit='unit', timestamps=[0.1, 0.2, 0.3] ) - iS = IndexSeries( - name='test_iS', + msg = ("The 'unit' field of IndexSeries is fixed to the value 'N/A' starting in NWB 2.5. Passing " + "a different value for 'unit' will raise an error in PyNWB 3.0.") + with self.assertWarnsWith(PendingDeprecationWarning, msg): + iS = IndexSeries( + name='test_iS', + data=[1, 2, 3], + unit='na', + indexed_timeseries=ts, + timestamps=[0.1, 0.2, 0.3] + ) + self.assertEqual(iS.unit, 'N/A') + + def test_init_indexed_ts(self): + ts = TimeSeries( + name='test_ts', data=[1, 2, 3], - unit='N/A', - indexed_timeseries=ts, + unit='unit', timestamps=[0.1, 0.2, 0.3] ) - self.assertEqual(iS.name, 'test_iS') - self.assertEqual(iS.unit, 'N/A') + msg = ("The indexed_timeseries field of IndexSeries is discouraged and will be deprecated in " + "a future version of NWB. Use the indexed_images field instead.") + with self.assertWarnsWith(PendingDeprecationWarning, msg): + iS = IndexSeries( + name='test_iS', + data=[1, 2, 3], + unit='N/A', + indexed_timeseries=ts, + timestamps=[0.1, 0.2, 0.3] + ) self.assertIs(iS.indexed_timeseries, ts)