diff --git a/spec/ndx-microscopy.extensions.yaml b/spec/ndx-microscopy.extensions.yaml index f908396..2fc474b 100644 --- a/spec/ndx-microscopy.extensions.yaml +++ b/spec/ndx-microscopy.extensions.yaml @@ -95,7 +95,7 @@ groups: doc: General estimate of location in the brain being subset by this space. Specify the area, layer, etc. Use standard atlas names for anatomical regions when possible. - Specify 'whole-brain' if the entire brain is strictly contained within the space. + Specify 'whole brain' if the entire brain is strictly contained within the space. required: false links: - name: microscope @@ -106,22 +106,14 @@ groups: neurodata_type_inc: ImagingSpace doc: Metadata about the 2-dimensional slice of physical space that imaging data was recorded from. datasets: - - name: grid_spacing + - name: grid_spacing_in_um dtype: float64 dims: - - x, y shape: - - 2 - doc: Amount of space between pixels in the specified unit. - Specify 'z' only when imaging volume is a regular grid; otherwise only specify 'x' and 'y'. - See origin_coordinates to determine where the grid begins. + doc: Amount of space between pixels in micrometers. quantity: '?' -# TODO: deal with grid_spacing units -# attributes: -# - name: unit -# dtype: text -# default_value: micrometers -# doc: Measurement units for grid spacing. The default value is 'micrometers'. attributes: - name: reference_frame dtype: text @@ -146,22 +138,14 @@ groups: neurodata_type_inc: ImagingSpace doc: Metadata about the 3-dimensional region of physical space that imaging data was recorded from. datasets: - - name: grid_spacing - doc: Amount of space between pixels in (x, y) or voxels in (x, y, z), in the specified unit. - Specify 'z' only when imaging volume is a regular grid; otherwise only specify 'x' and 'y'. - See origin_coordinates to determine where the grid begins. + - name: grid_spacing_in_um + doc: Amount of space between voxels in micrometers. dtype: float64 dims: - - x, y, z shape: - - 3 quantity: '?' -# TODO: deal with grid_spacing units -# attributes: -# - name: unit -# dtype: text -# default_value: micrometers -# doc: Measurement units for grid spacing. The default value is 'micrometers'. attributes: - name: reference_frame doc: Describes the reference frame of origin_coordinates and grid_spacing. @@ -182,6 +166,105 @@ groups: dtype: text required: false + + # These are needed to allow linkage of processed data to the new objects, until this is merged to core + # Technically the RoiResponseSeries shouldn't need to be modified since it just takes a DynamicTableRegion and + # does not care about the target + - neurodata_type_def: MicroscopySegmentations + neurodata_type_inc: NWBDataInterface + default_name: MicroscopySegmentations + doc: Stores pixels in an image that represent different regions of interest (ROIs) + or masks. All segmentation for a given imaging plane is stored together, with + storage for multiple imaging planes (masks) supported. Each ROI is stored in its + own subgroup, with the ROI group containing both a 2D mask and a list of pixels + that make up this mask. Segments can also be used for masking neuropil. If segmentation + is allowed to change with time, a new imaging plane (or module) is required and + ROI names should remain consistent between them. + groups: + - neurodata_type_inc: MicroscopyPlaneSegmentation + doc: Results from image segmentation of a specific imaging plane. + quantity: '+' + + + - neurodata_type_def: MicroscopyPlaneSegmentation + neurodata_type_inc: DynamicTable + doc: Results from image segmentation of a specific imaging plane. + datasets: + - name: image_mask + neurodata_type_inc: VectorData + dims: + - - num_roi + - num_x + - num_y + - - num_roi + - num_x + - num_y + - num_z + shape: + - - null + - null + - null + - - null + - null + - null + - null + doc: ROI masks for each ROI. Each image mask is the size of the original imaging + plane (or volume) and members of the ROI are finite non-zero. + quantity: '?' + - name: pixel_mask_index + neurodata_type_inc: VectorIndex + doc: Index into pixel_mask. + quantity: '?' + - name: pixel_mask + neurodata_type_inc: VectorData + dtype: + - name: x + dtype: uint32 + doc: Pixel x-coordinate. + - name: y + dtype: uint32 + doc: Pixel y-coordinate. + - name: weight + dtype: float32 + doc: Weight of the pixel. + doc: 'Pixel masks for each ROI: a list of indices and weights for the ROI. Pixel + masks are concatenated and parsing of this dataset is maintained by the PlaneSegmentation' + quantity: '?' + - name: voxel_mask_index + neurodata_type_inc: VectorIndex + doc: Index into voxel_mask. + quantity: '?' + - name: voxel_mask + neurodata_type_inc: VectorData + dtype: + - name: x + dtype: uint32 + doc: Voxel x-coordinate. + - name: y + dtype: uint32 + doc: Voxel y-coordinate. + - name: z + dtype: uint32 + doc: Voxel z-coordinate. + - name: weight + dtype: float32 + doc: Weight of the voxel. + doc: 'Voxel masks for each ROI: a list of indices and weights for the ROI. Voxel + masks are concatenated and parsing of this dataset is maintained by the PlaneSegmentation' + quantity: '?' + groups: + - name: summary_images + doc: Summary images that are related to the plane segmentation, e.g., mean, correlation, maximum projection. + groups: + - neurodata_type_inc: Images + doc: An container for the estimated summary images. + quantity: '*' + links: + - name: imaging_space + target_type: ImagingSpace + doc: Link to ImagingSpace object from which this data was generated. + + - neurodata_type_def: MicroscopySeries neurodata_type_inc: TimeSeries doc: Imaging data acquired over time from an optical channel in a microscope while a light source illuminates the @@ -227,7 +310,7 @@ groups: datasets: - name: depth_per_frame_in_mm doc: Depth of each frame in the data array. - These values offset the 'z' value of the origin_coordinates of the linked imaging_space object. + These values offset the 'z' value of the `origin_coordinates` of the linked `imaging_space` object. dtype: numeric dims: - frames @@ -259,6 +342,7 @@ groups: was recorded from. target_type: VolumetricImagingSpace + - neurodata_type_def: MultiChannelMicroscopyVolume neurodata_type_inc: NWBDataInterface doc: Static (not time-varying) volumetric imaging data acquired from multiple optical channels. @@ -336,3 +420,90 @@ groups: doc: Link to VolumetricImagingSpace object containing metadata about the region of physical space this imaging data was recorded from. target_type: VolumetricImagingSpace + + + - neurodata_type_def: VariableDepthMultiChannelMicroscopyVolume + neurodata_type_inc: NWBDataInterface + doc: Static (not time-varying) irregularly spaced volumetric imaging data acquired from multiple optical channels. + attributes: + - name: description + dtype: text + doc: Description of the VariableDepthMultiChannelMicroscopyVolume. + required: false + - name: unit + dtype: text + doc: Base unit of measurement for working with the data. Actual stored values are + not necessarily stored in these units. To access the data in these units, + multiply 'data' by 'conversion' and add 'offset'. + - name: conversion + dtype: float32 + default_value: 1.0 + doc: Scalar to multiply each element in data to convert it to the specified 'unit'. + If the data are stored in acquisition system units or other units + that require a conversion to be interpretable, multiply the data by 'conversion' + to convert the data to the specified 'unit'. e.g. if the data acquisition system + stores values in this object as signed 16-bit integers (int16 range + -32,768 to 32,767) that correspond to a 5V range (-2.5V to 2.5V), and the data + acquisition system gain is 8000X, then the 'conversion' multiplier to get from + raw data acquisition values to recorded volts is 2.5/32768/8000 = 9.5367e-9. + required: false + - name: offset + dtype: float32 + default_value: 0.0 + doc: Scalar to add to the data after scaling by 'conversion' to finalize its coercion + to the specified 'unit'. Two common examples of this include (a) data stored in an + unsigned type that requires a shift after scaling to re-center the data, + and (b) specialized recording devices that naturally cause a scalar offset with + respect to the true units. + required: false + datasets: + - name: data + doc: Recorded imaging data, shaped by (frame height, frame width, number of depth planes, number of optical + channels). + dtype: numeric + dims: + - height + - width + - depths + - channels + shape: + - null + - null + - null + - null + - name: depth_per_frame_in_mm + doc: Depth of each frame in the data array. + These values offset the 'z' value of the `origin_coordinates` of the linked `imaging_space` object. + dtype: numeric + dims: + - depths + shape: + - null + - name: light_sources + doc: An ordered list of references to MicroscopyLightSource objects containing metadata about the excitation methods. + neurodata_type_inc: VectorData + dtype: + reftype: object + target_type: MicroscopyLightSource + dims: + - light_sources + shape: + - null + - name: optical_channels + doc: An ordered list of references to MicroscopyOpticalChannel objects containing metadata about the indicator and filters used to collect this data. This maps to the last dimension of `data`, i.e., the i-th MicroscopyOpticalChannel contains metadata about the indicator and filters used to collect the volume at `data[:,:,:,i]`. + neurodata_type_inc: VectorData + dtype: + reftype: object + target_type: MicroscopyOpticalChannel + dims: + - optical_channels + shape: + - null + links: + - name: microscope + doc: Link to a Microscope object containing metadata about the device used to acquire this imaging data. + target_type: Microscope + - name: imaging_space + doc: Link to VolumetricImagingSpace object containing metadata about the region of physical space this imaging data + was recorded from. + target_type: VolumetricImagingSpace diff --git a/src/pynwb/ndx_microscopy/__init__.py b/src/pynwb/ndx_microscopy/__init__.py index 722b281..8dfeac9 100644 --- a/src/pynwb/ndx_microscopy/__init__.py +++ b/src/pynwb/ndx_microscopy/__init__.py @@ -26,11 +26,14 @@ ImagingSpace = get_class("ImagingSpace", extension_name) PlanarImagingSpace = get_class("PlanarImagingSpace", extension_name) VolumetricImagingSpace = get_class("VolumetricImagingSpace", extension_name) +MicroscopySegmentations = get_class("MicroscopySegmentations", extension_name) +MicroscopyPlaneSegmentation = get_class("MicroscopyPlaneSegmentation", extension_name) MicroscopySeries = get_class("MicroscopySeries", extension_name) PlanarMicroscopySeries = get_class("PlanarMicroscopySeries", extension_name) VariableDepthMicroscopySeries = get_class("VariableDepthMicroscopySeries", extension_name) VolumetricMicroscopySeries = get_class("VolumetricMicroscopySeries", extension_name) MultiChannelMicroscopyVolume = get_class("MultiChannelMicroscopyVolume", extension_name) +VariableDepthMultiChannelMicroscopyVolume = get_class("VariableDepthMultiChannelMicroscopyVolume", extension_name) __all__ = [ "Microscope", @@ -39,9 +42,12 @@ "ImagingSpace", "PlanarImagingSpace", "VolumetricImagingSpace", + "MicroscopySegmentations", + "MicroscopyPlaneSegmentation", "MicroscopySeries", "PlanarMicroscopySeries", "VariableDepthMicroscopySeries", "VolumetricMicroscopySeries", "MultiChannelMicroscopyVolume", + "VariableDepthMultiChannelMicroscopyVolume", ] diff --git a/src/pynwb/ndx_microscopy/testing/__init__.py b/src/pynwb/ndx_microscopy/testing/__init__.py index b305dac..e27103f 100644 --- a/src/pynwb/ndx_microscopy/testing/__init__.py +++ b/src/pynwb/ndx_microscopy/testing/__init__.py @@ -2,10 +2,13 @@ mock_Microscope, mock_MicroscopyLightSource, mock_MicroscopyOpticalChannel, + mock_MicroscopyPlaneSegmentation, + mock_MicroscopySegmentations, mock_MultiChannelMicroscopyVolume, mock_PlanarImagingSpace, mock_PlanarMicroscopySeries, mock_VariableDepthMicroscopySeries, + mock_VariableDepthMultiChannelMicroscopyVolume, mock_VolumetricImagingSpace, mock_VolumetricMicroscopySeries, ) @@ -16,8 +19,11 @@ "mock_MicroscopyOpticalChannel", "mock_PlanarImagingSpace", "mock_VolumetricImagingSpace", + "mock_MicroscopySegmentations", + "mock_MicroscopyPlaneSegmentation", "mock_PlanarMicroscopySeries", "mock_VariableDepthMicroscopySeries", "mock_VolumetricMicroscopySeries", "mock_MultiChannelMicroscopyVolume", + "mock_VariableDepthMultiChannelMicroscopyVolume", ] diff --git a/src/pynwb/ndx_microscopy/testing/_mock.py b/src/pynwb/ndx_microscopy/testing/_mock.py index 7024cdb..482e308 100644 --- a/src/pynwb/ndx_microscopy/testing/_mock.py +++ b/src/pynwb/ndx_microscopy/testing/_mock.py @@ -1,5 +1,5 @@ import warnings -from typing import List, Optional, Tuple +from typing import Iterable, List, Optional, Tuple import numpy as np import pynwb.base @@ -78,7 +78,7 @@ def mock_PlanarImagingSpace( name: Optional[str] = None, description: str = "This is a mock instance of a PlanarImagingSpace type to be used for rapid testing.", origin_coordinates: Tuple[float, float, float] = (-1.2, -0.6, -2), - grid_spacing: Tuple[float, float, float] = (0.2, 0.2), + grid_spacing_in_um: Tuple[float, float, float] = (20, 20), location: str = "The location targeted by the mock imaging space.", reference_frame: str = "The reference frame of the mock planar imaging space.", ) -> ndx_microscopy.PlanarImagingSpace: @@ -87,7 +87,7 @@ def mock_PlanarImagingSpace( description=description, microscope=microscope, origin_coordinates=origin_coordinates, - grid_spacing=grid_spacing, + grid_spacing_in_um=grid_spacing_in_um, location=location, reference_frame=reference_frame, ) @@ -100,7 +100,7 @@ def mock_VolumetricImagingSpace( name: Optional[str] = None, description: str = "This is a mock instance of a VolumetricImagingSpace type to be used for rapid testing.", origin_coordinates: Tuple[float, float, float] = (-1.2, -0.6, -2), - grid_spacing: Tuple[float, float, float] = (0.2, 0.2, 0.5), + grid_spacing_in_um: Tuple[float, float, float] = (20, 20, 50), location: str = "The location targeted by the mock imaging space.", reference_frame: str = "The reference frame of the mock volumetric imaging space.", ) -> ndx_microscopy.VolumetricImagingSpace: @@ -109,13 +109,56 @@ def mock_VolumetricImagingSpace( description=description, microscope=microscope, origin_coordinates=origin_coordinates, - grid_spacing=grid_spacing, + grid_spacing_in_um=grid_spacing_in_um, location=location, reference_frame=reference_frame, ) return volumetric_imaging_space +def mock_MicroscopySegmentations( + *, + name: Optional[str] = None, + microscopy_plane_segmentations: Optional[Iterable[ndx_microscopy.MicroscopyPlaneSegmentation]] = None, +) -> ndx_microscopy.MicroscopySegmentations: + name = name or name_generator("MicroscopySegmentations") + + microscope = mock_Microscope() + imaging_space = mock_PlanarImagingSpace(microscope=microscope) + microscopy_plane_segmentations = microscopy_plane_segmentations or [ + mock_MicroscopyPlaneSegmentation(imaging_space=imaging_space) + ] + + segmentations = ndx_microscopy.MicroscopySegmentations( + name=name, microscopy_plane_segmentations=microscopy_plane_segmentations + ) + + return segmentations + + +def mock_MicroscopyPlaneSegmentation( + *, + imaging_space: ndx_microscopy.ImagingSpace, + name: Optional[str] = None, + description: str = "This is a mock instance of a MicroscopyPlaneSegmentation type to be used for rapid testing.", + number_of_rois: int = 5, + image_shape: Tuple[int, int] = (10, 10), +) -> ndx_microscopy.MicroscopyPlaneSegmentation: + name = name or name_generator("MicroscopyPlaneSegmentation") + + plane_segmentation = ndx_microscopy.MicroscopyPlaneSegmentation( + name=name, description=description, imaging_space=imaging_space, id=list(range(number_of_rois)) + ) + # plane_segmentation.add_column(name="id", description="", data=list(range(number_of_rois))) + + image_masks = list() + for _ in range(number_of_rois): + image_masks.append(np.zeros(image_shape, dtype=bool)) + plane_segmentation.add_column(name="image_mask", description="", data=image_masks) + + return plane_segmentation + + def mock_PlanarMicroscopySeries( *, microscope: ndx_microscopy.Microscope, @@ -190,7 +233,6 @@ def mock_VariableDepthMicroscopySeries( ) -> ndx_microscopy.VariableDepthMicroscopySeries: series_name = name or name_generator("VariableDepthMicroscopySeries") series_data = data if data is not None else np.ones(shape=(15, 5, 5)) - series_depth_per_frame_in_mm = ( depth_per_frame_in_mm if depth_per_frame_in_mm is not None @@ -318,3 +360,43 @@ def mock_MultiChannelMicroscopyVolume( offset=offset, ) return volumetric_microscopy_series + + +def mock_VariableDepthMultiChannelMicroscopyVolume( + *, + microscope: ndx_microscopy.Microscope, + imaging_space: ndx_microscopy.VolumetricImagingSpace, + light_sources: pynwb.base.VectorData, + optical_channels: pynwb.base.VectorData, + name: Optional[str] = None, + description: str = "This is a mock instance of a MultiChannelMicroscopyVolume type to be used for rapid testing.", + data: Optional[np.ndarray] = None, + depth_per_frame_in_mm: Optional[np.ndarray] = None, + unit: str = "n.a.", + conversion: float = 1.0, + offset: float = 0.0, +) -> ndx_microscopy.VariableDepthMultiChannelMicroscopyVolume: + series_name = name or name_generator("MultiChannelMicroscopyVolume") + + series_data = data if data is not None else np.ones(shape=(15, 5, 5)) + volume_depth_per_frame_in_mm = ( + depth_per_frame_in_mm + if depth_per_frame_in_mm is not None + else np.linspace(start=0.0, stop=1.0, num=series_data.shape[0]) + ) + + imaging_data = data if data is not None else np.ones(shape=(10, 20, 7, 3)) + variable_depth_multi_channel_microscopy_volume = ndx_microscopy.VariableDepthMultiChannelMicroscopyVolume( + name=series_name, + description=description, + microscope=microscope, + imaging_space=imaging_space, + light_sources=light_sources, + optical_channels=optical_channels, + data=imaging_data, + depth_per_frame_in_mm=volume_depth_per_frame_in_mm, + unit=unit, + conversion=conversion, + offset=offset, + ) + return variable_depth_multi_channel_microscopy_volume diff --git a/src/pynwb/tests/test_constructors.py b/src/pynwb/tests/test_constructors.py index 7585c1d..f5c29aa 100644 --- a/src/pynwb/tests/test_constructors.py +++ b/src/pynwb/tests/test_constructors.py @@ -7,10 +7,13 @@ mock_Microscope, mock_MicroscopyLightSource, mock_MicroscopyOpticalChannel, + mock_MicroscopyPlaneSegmentation, + mock_MicroscopySegmentations, mock_MultiChannelMicroscopyVolume, mock_PlanarImagingSpace, mock_PlanarMicroscopySeries, mock_VariableDepthMicroscopySeries, + mock_VariableDepthMultiChannelMicroscopyVolume, mock_VolumetricImagingSpace, mock_VolumetricMicroscopySeries, ) @@ -40,6 +43,32 @@ def test_constructor_volumetric_image_space(): mock_VolumetricImagingSpace(microscope=microscope) +def test_constructor_microscopy_segmentations(): + mock_MicroscopySegmentations() + + +def test_constructor_microscopy_plane_segmentation(): + microscope = mock_Microscope() + imaging_space = mock_PlanarImagingSpace(microscope=microscope) + + mock_MicroscopyPlaneSegmentation(imaging_space=imaging_space) + + +def test_constructor_microscopy_image_segmentation_with_plane_segmentation(): + microscope = mock_Microscope() + imaging_space = mock_PlanarImagingSpace(microscope=microscope) + + plane_segmentation_1 = mock_MicroscopyPlaneSegmentation( + imaging_space=imaging_space, name="MicroscopyPlaneSegmentation1" + ) + plane_segmentation_2 = mock_MicroscopyPlaneSegmentation( + imaging_space=imaging_space, name="MicroscopyPlaneSegmentation2" + ) + microscopy_plane_segmentations = [plane_segmentation_1, plane_segmentation_2] + + mock_MicroscopySegmentations(microscopy_plane_segmentations=microscopy_plane_segmentations) + + def test_constructor_planar_microscopy_series(): microscope = mock_Microscope() light_source = mock_MicroscopyLightSource() @@ -98,5 +127,30 @@ def test_constructor_multi_channel_microscopy_volume(): ) +def test_constructor_variable_depth_multi_channel_microscopy_volume(): + microscope = mock_Microscope() + imaging_space = mock_VolumetricImagingSpace(microscope=microscope) + light_sources = [mock_MicroscopyLightSource()] + optical_channels = [mock_MicroscopyOpticalChannel()] + + light_sources_used_by_volume = pynwb.base.VectorData( + name="light_sources", description="Light sources used by this MultiChannelVolume.", data=light_sources + ) + optical_channels_used_by_volume = pynwb.base.VectorData( + name="optical_channels", + description=( + "Optical channels ordered to correspond to the third axis (e.g., [0, 0, :, 0]) " + "of the data for this MultiChannelVolume." + ), + data=optical_channels, + ) + mock_VariableDepthMultiChannelMicroscopyVolume( + microscope=microscope, + imaging_space=imaging_space, + light_sources=light_sources_used_by_volume, + optical_channels=optical_channels_used_by_volume, + ) + + if __name__ == "__main__": pytest.main() # Required since not a typical package structure diff --git a/src/pynwb/tests/test_roundtrip.py b/src/pynwb/tests/test_roundtrip.py index 530a703..4d9f830 100644 --- a/src/pynwb/tests/test_roundtrip.py +++ b/src/pynwb/tests/test_roundtrip.py @@ -1,5 +1,6 @@ """Test roundtrip (write and read back) of the Python API for the ndx-microscopy extension.""" +import pytest from pynwb.testing import TestCase as pynwb_TestCase from pynwb.testing.mock.file import mock_NWBFile @@ -8,6 +9,8 @@ mock_Microscope, mock_MicroscopyLightSource, mock_MicroscopyOpticalChannel, + mock_MicroscopyPlaneSegmentation, + mock_MicroscopySegmentations, mock_MultiChannelMicroscopyVolume, mock_PlanarImagingSpace, mock_PlanarMicroscopySeries, @@ -232,5 +235,50 @@ def test_roundtrip(self): ) +class TestMicroscopySegmentationsSimpleRoundtrip(pynwb_TestCase): + """Simple roundtrip test for MicroscopySegmentations.""" + + def setUp(self): + self.nwbfile_path = "test_microscopy_segmentations_roundtrip.nwb" + + def tearDown(self): + pynwb.testing.remove_test_file(self.nwbfile_path) + + def test_roundtrip(self): + nwbfile = mock_NWBFile() + + microscope = mock_Microscope(name="Microscope") + nwbfile.add_device(devices=microscope) + + imaging_space = mock_PlanarImagingSpace(name="PlanarImagingSpace", microscope=microscope) + nwbfile.add_lab_meta_data(lab_meta_data=imaging_space) # Would prefer .add_imaging_space() + + plane_segmentation_1 = mock_MicroscopyPlaneSegmentation( + imaging_space=imaging_space, name="MicroscopyPlaneSegmentation1" + ) + plane_segmentation_2 = mock_MicroscopyPlaneSegmentation( + imaging_space=imaging_space, name="MicroscopyPlaneSegmentation2" + ) + microscopy_plane_segmentations = [plane_segmentation_1, plane_segmentation_2] + + segmentations = mock_MicroscopySegmentations( + name="MicroscopySegmentations", microscopy_plane_segmentations=microscopy_plane_segmentations + ) + processing_module = nwbfile.create_processing_module(name="ophys", description="") + processing_module.add(segmentations) + + with pynwb.NWBHDF5IO(path=self.nwbfile_path, mode="w") as io: + io.write(nwbfile) + + with pynwb.NWBHDF5IO(path=self.nwbfile_path, mode="r", load_namespaces=True) as io: + read_nwbfile = io.read() + + self.assertContainerEqual(microscope, read_nwbfile.devices["Microscope"]) + + self.assertContainerEqual(imaging_space, read_nwbfile.lab_meta_data["PlanarImagingSpace"]) + + self.assertContainerEqual(segmentations, read_nwbfile.processing["ophys"]["MicroscopySegmentations"]) + + if __name__ == "__main__": pytest.main() # Required since not a typical package structure