diff --git a/CHANGELOG.md b/CHANGELOG.md index 52f69b02b..e5f648b6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Fixed reading a file whose dates carry a sub-minute UTC offset (e.g. `1900-10-01T00:00:00-05:50:36`). @h-mayorquin [#2230](https://github.com/NeurodataWithoutBorders/pynwb/pull/2230) - Fixed wide pandas DataFrames in the tutorials spilling out of the content column and into the right margin. @bendichter [#2236](https://github.com/NeurodataWithoutBorders/pynwb/pull/2236) - Fixed `set_data_io` being silently ignored on `NWBData` subclasses (`GrayscaleImage`, `RGBImage`, `RGBAImage`, `ExternalImage`, `ImageReferences`, and `ScratchData`), so requested chunking and compression were dropped without warning and the datasets were written uncompressed. @h-mayorquin [#2233](https://github.com/NeurodataWithoutBorders/pynwb/pull/2233) +- Fixed `ImageSeries` (and its subclasses) writing a `num_samples` dataset derived from `len(data)` when the user never set one, which emitted an HDMF `DtypeConversionWarning` on write. `num_samples` is now written only when it was explicitly provided, and an explicitly provided value is written with the schema's `uint32` dtype instead of being widened to `uint64`. Reading a file that contains `num_samples` is unchanged, as is the in-memory `ImageSeries.num_samples` property. @adityasingh2400 [#2239](https://github.com/NeurodataWithoutBorders/pynwb/pull/2239) ## PyNWB 4.1.0 (July 23, 2026) diff --git a/src/pynwb/io/image.py b/src/pynwb/io/image.py index afa47c35c..4619e58ac 100644 --- a/src/pynwb/io/image.py +++ b/src/pynwb/io/image.py @@ -1,6 +1,9 @@ +import numpy as np + from .. import register_map from ..image import ImageSeries from .base import TimeSeriesMap +from .utils import NO_OVERRIDE @register_map(ImageSeries) @@ -10,3 +13,24 @@ def __init__(self, spec): super().__init__(spec) external_file_spec = self.spec.get_dataset('external_file') self.map_spec('starting_frame', external_file_spec.get_attribute('starting_frame')) + + # ``ImageSeries.num_samples`` falls back to the inherited ``TimeSeries.num_samples`` + # property, i.e. ``len(data)``, when the user did not supply a value. Writing that derived + # value would persist a dataset the user never set, so map the write path to the private + # ``_num_samples``, which holds a value only when ``num_samples`` was explicitly provided. + # The read path is untouched, so the dataset still maps to the ``num_samples`` + # constructor argument. + num_samples_spec = self.spec.get_dataset('num_samples') + self.map_attr('_num_samples', num_samples_spec) + + @TimeSeriesMap.object_attr('_num_samples') + def num_samples_attr(self, container, manager): + num_samples = container._num_samples + if num_samples is None: + return NO_OVERRIDE + # the schema dtype of num_samples is uint32. Cast an in-range value so HDMF writes it as + # uint32 instead of widening a Python int to uint64 and emitting a DtypeConversionWarning. + # Out-of-range values are passed through so HDMF reports the mismatch as usual. + if 0 <= num_samples <= np.iinfo(np.uint32).max: + return np.uint32(num_samples) + return num_samples diff --git a/tests/integration/hdf5/test_image.py b/tests/integration/hdf5/test_image.py index 0430ae4bd..a9c1cfdf2 100644 --- a/tests/integration/hdf5/test_image.py +++ b/tests/integration/hdf5/test_image.py @@ -1,9 +1,15 @@ +import warnings +from datetime import datetime + +import h5py import numpy as np +from dateutil.tz import tzutc +from pynwb import NWBFile, NWBHDF5IO from pynwb.base import Image, ImageReferences, Images from pynwb.device import Device from pynwb.image import ImageSeries, IndexSeries, OpticalSeries -from pynwb.testing import AcquisitionH5IOMixin, NWBH5IOMixin, TestCase +from pynwb.testing import AcquisitionH5IOMixin, NWBH5IOMixin, TestCase, remove_test_file class TestImageSeriesIO(AcquisitionH5IOMixin, TestCase): @@ -49,6 +55,84 @@ def addContainer(self, nwbfile): nwbfile.add_device(self.dev1) super().addContainer(nwbfile) + def test_num_samples_written_as_uint32(self): + """An explicitly set num_samples should be written with the schema dtype (uint32).""" + read_container = self.roundtripContainer() + self.assertEqual(read_container.num_samples, 900) + with h5py.File(self.filename, 'r') as infile: + dset = infile['acquisition'][self.container.name]['num_samples'] + self.assertEqual(dset.dtype, np.uint32) + self.assertEqual(dset[()], 900) + + +class TestImageSeriesDerivedNumSamplesIO(AcquisitionH5IOMixin, TestCase): + """Roundtrip test for an ImageSeries with internal data and no explicit num_samples.""" + + def setUpContainer(self): + return ImageSeries( + name='test_iS_derived_num_samples', + data=np.zeros((10, 5, 5), dtype=np.uint8), + unit='n.a.', + rate=1.0, + ) + + def test_derived_num_samples_not_written(self): + """num_samples derived from len(data) should not be persisted.""" + self.assertIsNone(self.container._num_samples) + self.assertEqual(self.container.num_samples, 10) # derived from len(data) + + read_container = self.roundtripContainer() + with h5py.File(self.filename, 'r') as infile: + self.assertNotIn('num_samples', infile['acquisition'][self.container.name]) + + # the value is still available in memory, derived from the data that was read back + self.assertIsNone(read_container._num_samples) + self.assertEqual(read_container.num_samples, 10) + + +class TestImageSeriesNumSamplesWriteWarnings(TestCase): + """Writing num_samples should not emit a DtypeConversionWarning. See #2227.""" + + def setUp(self): + self.filename = 'test_image_series_num_samples.nwb' + + def tearDown(self): + remove_test_file(self.filename) + + def write_and_collect_warnings(self, image_series): + nwbfile = NWBFile( + session_description='a file to test writing ImageSeries.num_samples', + identifier='TEST_num_samples', + session_start_time=datetime(1971, 1, 1, 12, tzinfo=tzutc()), + ) + nwbfile.add_acquisition(image_series) + with warnings.catch_warnings(record=True) as ws: + warnings.simplefilter('always') + with NWBHDF5IO(self.filename, mode='w') as write_io: + write_io.write(nwbfile) + return [str(w.message) for w in ws if 'num_samples' in str(w.message)] + + def test_no_warning_for_derived_num_samples(self): + image_series = ImageSeries( + name='test_iS', + data=np.zeros((10, 5, 5), dtype=np.uint8), + unit='n.a.', + rate=1.0, + ) + self.assertEqual(self.write_and_collect_warnings(image_series), []) + + def test_no_warning_for_explicit_num_samples(self): + image_series = ImageSeries( + name='test_iS', + unit='n.a.', + external_file=['external_file'], + starting_frame=[0], + format='external', + rate=30.0, + num_samples=900, + ) + self.assertEqual(self.write_and_collect_warnings(image_series), []) + class TestIndexSeriesIO(AcquisitionH5IOMixin, TestCase):