Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions src/pynwb/io/image.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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
86 changes: 85 additions & 1 deletion tests/integration/hdf5/test_image.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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):

Expand Down