Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Enhancement I] Add multichannel volume #13

Merged
merged 21 commits into from
Jul 15, 2024
Merged
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
80 changes: 79 additions & 1 deletion spec/ndx-microscopy.extensions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ groups:
- frames
- height
- width
- depth
- depths
shape:
- null
- null
Expand All @@ -258,3 +258,81 @@ 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: MultiChannelMicroscopyVolume
neurodata_type_inc: NWBDataInterface
doc: Static (not time-varying) volumetric imaging data acquired from multiple optical channels.
attributes:
- name: description
dtype: text
doc: Description of the MultiChannelVolume.
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
- optical_channels
shape:
- null
- null
- null
- 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
CodyCBakerPhD marked this conversation as resolved.
Show resolved Hide resolved
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
CodyCBakerPhD marked this conversation as resolved.
Show resolved Hide resolved
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
2 changes: 2 additions & 0 deletions src/pynwb/ndx_microscopy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
PlanarMicroscopySeries = get_class("PlanarMicroscopySeries", extension_name)
VariableDepthMicroscopySeries = get_class("VariableDepthMicroscopySeries", extension_name)
VolumetricMicroscopySeries = get_class("VolumetricMicroscopySeries", extension_name)
MultiChannelMicroscopyVolume = get_class("MultiChannelMicroscopyVolume", extension_name)

__all__ = [
"Microscope",
Expand All @@ -42,4 +43,5 @@
"PlanarMicroscopySeries",
"VariableDepthMicroscopySeries",
"VolumetricMicroscopySeries",
"MultiChannelMicroscopyVolume",
]
2 changes: 2 additions & 0 deletions src/pynwb/ndx_microscopy/testing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
mock_Microscope,
mock_MicroscopyLightSource,
mock_MicroscopyOpticalChannel,
mock_MultiChannelMicroscopyVolume,
mock_PlanarImagingSpace,
mock_PlanarMicroscopySeries,
mock_VariableDepthMicroscopySeries,
Expand All @@ -18,4 +19,5 @@
"mock_PlanarMicroscopySeries",
"mock_VariableDepthMicroscopySeries",
"mock_VolumetricMicroscopySeries",
"mock_MultiChannelMicroscopyVolume",
]
34 changes: 33 additions & 1 deletion src/pynwb/ndx_microscopy/testing/_mock.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import warnings
from typing import Optional, Tuple
from typing import List, Optional, Tuple

import numpy as np
import pynwb.base
from pynwb.testing.mock.utils import name_generator

import ndx_microscopy
Expand Down Expand Up @@ -286,3 +287,34 @@ def mock_VolumetricMicroscopySeries(
timestamps=series_timestamps,
)
return volumetric_microscopy_series


def mock_MultiChannelMicroscopyVolume(
*,
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,
unit: str = "n.a.",
conversion: float = 1.0,
offset: float = 0.0,
) -> ndx_microscopy.MultiChannelMicroscopyVolume:
series_name = name or name_generator("MultiChannelMicroscopyVolume")
imaging_data = data if data is not None else np.ones(shape=(10, 20, 7, 3))

volumetric_microscopy_series = ndx_microscopy.MultiChannelMicroscopyVolume(
name=series_name,
description=description,
microscope=microscope,
imaging_space=imaging_space,
light_sources=light_sources,
optical_channels=optical_channels,
data=imaging_data,
unit=unit,
conversion=conversion,
offset=offset,
)
return volumetric_microscopy_series
27 changes: 27 additions & 0 deletions src/pynwb/tests/test_constructors.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

import pytest

import pynwb
from ndx_microscopy.testing import (
mock_Microscope,
mock_MicroscopyLightSource,
mock_MicroscopyOpticalChannel,
mock_MultiChannelMicroscopyVolume,
mock_PlanarImagingSpace,
mock_PlanarMicroscopySeries,
mock_VariableDepthMicroscopySeries,
Expand Down Expand Up @@ -71,5 +73,30 @@ def test_constructor_volumetric_microscopy_series():
)


def test_constructor_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_MultiChannelMicroscopyVolume(
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
80 changes: 76 additions & 4 deletions src/pynwb/tests/test_roundtrip.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
mock_Microscope,
mock_MicroscopyLightSource,
mock_MicroscopyOpticalChannel,
mock_MultiChannelMicroscopyVolume,
mock_PlanarImagingSpace,
mock_PlanarMicroscopySeries,
mock_VariableDepthMicroscopySeries,
Expand All @@ -20,7 +21,7 @@ class TestPlanarMicroscopySeriesSimpleRoundtrip(pynwb_TestCase):
"""Simple roundtrip test for PlanarMicroscopySeries."""

def setUp(self):
self.nwbfile_path = "test.nwb"
self.nwbfile_path = "test_planar_microscopy_series_roundtrip.nwb"

def tearDown(self):
pynwb.testing.remove_test_file(self.nwbfile_path)
Expand Down Expand Up @@ -68,7 +69,7 @@ class TestVolumetricMicroscopySeriesSimpleRoundtrip(pynwb_TestCase):
"""Simple roundtrip test for VolumetricMicroscopySeries."""

def setUp(self):
self.nwbfile_path = "test.nwb"
self.nwbfile_path = "test_volumetric_microscopy_series_roundtrip.nwb"

def tearDown(self):
pynwb.testing.remove_test_file(self.nwbfile_path)
Expand Down Expand Up @@ -118,7 +119,7 @@ class TestVariableDepthMicroscopySeriesSimpleRoundtrip(pynwb_TestCase):
"""Simple roundtrip test for VariableDepthMicroscopySeries."""

def setUp(self):
self.nwbfile_path = "test.nwb"
self.nwbfile_path = "test_variable_depth_microscopy_series_roundtrip.nwb"

def tearDown(self):
pynwb.testing.remove_test_file(self.nwbfile_path)
Expand All @@ -133,7 +134,7 @@ def test_roundtrip(self):
nwbfile.add_device(devices=light_source)

imaging_space = mock_PlanarImagingSpace(name="PlanarImagingSpace", microscope=microscope)
nwbfile.add_lab_meta_data(lab_meta_data=imaging_space) # Would prefer .add_imaging_spacec()
nwbfile.add_lab_meta_data(lab_meta_data=imaging_space) # Would prefer .add_imaging_space()

optical_channel = mock_MicroscopyOpticalChannel(name="MicroscopyOpticalChannel")
nwbfile.add_lab_meta_data(lab_meta_data=optical_channel)
Expand Down Expand Up @@ -162,3 +163,74 @@ def test_roundtrip(self):
self.assertContainerEqual(
variable_depth_microscopy_series, read_nwbfile.acquisition["VariableDepthMicroscopySeries"]
)


class TestMultiChannelMicroscopyVolumeSimpleRoundtrip(pynwb_TestCase):
"""Simple roundtrip test for MultiChannelMicroscopyVolume."""

def setUp(self):
self.nwbfile_path = "test_multi_channel_microscopy_volume_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_VolumetricImagingSpace(name="VolumetricImagingSpace", microscope=microscope)
nwbfile.add_lab_meta_data(lab_meta_data=imaging_space) # Would prefer .add_imaging_space()

light_sources = list()
light_source_0 = mock_MicroscopyLightSource(name="LightSource")
nwbfile.add_device(devices=light_source_0)
light_sources.append(light_source_0)

optical_channels = list()
optical_channel_0 = mock_MicroscopyOpticalChannel(name="MicroscopyOpticalChannel")
nwbfile.add_lab_meta_data(lab_meta_data=optical_channel_0)
optical_channels.append(optical_channel_0)

# TODO: It might be more convenient in Python to have a custom constructor that takes in a list of
# light sources and optical channels and does the VectorData wrapping internally
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,
)
multi_channel_microscopy_volume = mock_MultiChannelMicroscopyVolume(
name="MultiChannelMicroscopyVolume",
microscope=microscope,
imaging_space=imaging_space,
light_sources=light_sources_used_by_volume,
optical_channels=optical_channels_used_by_volume,
)
nwbfile.add_acquisition(nwbdata=multi_channel_microscopy_volume)

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(light_source_0, read_nwbfile.devices["LightSource"])

self.assertContainerEqual(imaging_space, read_nwbfile.lab_meta_data["VolumetricImagingSpace"])
self.assertContainerEqual(optical_channel_0, read_nwbfile.lab_meta_data["MicroscopyOpticalChannel"])

self.assertContainerEqual(
multi_channel_microscopy_volume, read_nwbfile.acquisition["MultiChannelMicroscopyVolume"]
)


if __name__ == "__main__":
pytest.main() # Required since not a typical package structure
Loading