From d3f25b81a7eb4864e3eccf12c8d96272b08ef5bd Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Wed, 13 Sep 2023 17:35:51 -0700 Subject: [PATCH 01/47] added multi-plane support to scanimage (first draft) --- .../scanimagetiffimagingextractor.py | 161 +++++++++++++++++- tests/test_multiplane_scanimage.py | 29 ++++ 2 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 tests/test_multiplane_scanimage.py diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index 83a51916..0e4a6fe2 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -8,8 +8,10 @@ from pathlib import Path from typing import Optional, Tuple from warnings import warn - import numpy as np +from pprint import pprint + +from neuroconv.datainterfaces.ophys.scanimage.scanimageimaginginterface import extract_extra_metadata from ...extraction_tools import PathType, FloatType, ArrayType, get_package from ...imagingextractor import ImagingExtractor @@ -22,6 +24,163 @@ def _get_scanimage_reader() -> type: ).ScanImageTiffReader +class ScanImageTiffMultiPlaneImagingExtractor( + ImagingExtractor +): # TODO: Refactor to avoid copy-paste from ScanImageTiffImagingExtractor + extractor_name = "ScanImageTiffMultiPlaneImaging" + is_writable = True + mode = "file" + + def __init__(self, file_path: PathType) -> None: + super().__init__() + self.file_path = Path(file_path) + ScanImageTiffReader = _get_scanimage_reader() + extra_metadata = extract_extra_metadata(file_path) + # SI.hChannels.channelsActive = '[1;2;...;N]' where N is the number of active channels + self._num_channels = len(extra_metadata["SI.hChannels.channelsActive"].split(";")) + self._num_planes = int(extra_metadata["SI.hStackManager.numSlices"]) + frames_per_slice = int(extra_metadata["SI.hStackManager.framesPerSlice"]) + if frames_per_slice != 1: + raise NotImplementedError( + "Extractor cannot handle multiple frames per slice. Please raise an issue to request this feature: " + "https://github.com/catalystneuro/roiextractors/issues " + ) + self._sampling_frequency = float(extra_metadata["SI.hRoiManager.scanVolumeRate"]) + # SI.hChannels.channelName = "{'channel_name_1' 'channel_name_2' ... 'channel_name_N'}" where N is the number of channels (active or not) + self._channel_names = extra_metadata["SI.hChannels.channelName"].split("'")[1::2][: self._num_channels] + + valid_suffixes = [".tiff", ".tif", ".TIFF", ".TIF"] + if self.file_path.suffix not in valid_suffixes: + suffix_string = ", ".join(valid_suffixes[:-1]) + f", or {valid_suffixes[-1]}" + warn( + f"Suffix ({self.file_path.suffix}) is not of type {suffix_string}! " + f"The {self.extractor_name}Extractor may not be appropriate for the file." + ) + with ScanImageTiffReader(str(self.file_path)) as io: + shape = io.shape() # [frames, rows, columns] + if len(shape) == 3: + self._total_num_frames, self._num_rows, self._num_columns = shape + self._num_frames = self._total_num_frames // (self._num_planes * self._num_channels) + else: + raise NotImplementedError( + "Extractor cannot handle 4D TIFF data. Please raise an issue to request this feature: " + "https://github.com/catalystneuro/roiextractors/issues " + ) + + def get_frames(self, frame_idxs: ArrayType, channel: int = 0, plane: int = 0) -> np.ndarray: + """Get specific video frames from indices (not necessarily continuous). + + Parameters + ---------- + frame_idxs: array-like + Indices of frames to return. + channel: int, optional + Channel index. + plane: int, optional + Plane index. + + Returns + ------- + frames: numpy.ndarray + The video frames. + """ + self.check_frame_inputs(frame_idxs[-1], channel, plane) + if isinstance(frame_idxs, int): + frame_idxs = [frame_idxs] + + if not all(np.diff(frame_idxs) == 1): + return np.concatenate([self._get_single_frame(frame=idx) for idx in frame_idxs]) + else: + return self.get_video(start_frame=frame_idxs[0], end_frame=frame_idxs[-1] + 1, channel=channel) + + # Data accessed through an open ScanImageTiffReader io gets scrambled if there are multiple calls. + # Thus, open fresh io in context each time something is needed. + def _get_single_frame(self, frame: int, channel: Optional[int] = 0, plane: Optional[int] = 0) -> np.ndarray: + """Get a single frame of data from the TIFF file. + + Parameters + ---------- + frame : int + The index of the frame to retrieve. + channel : int, optional + The index of the channel to retrieve. + plane : int, optional + The index of the plane to retrieve. + + Returns + ------- + frame: numpy.ndarray + The frame of data. + """ + self.check_frame_inputs(frame, channel, plane) + ScanImageTiffReader = _get_scanimage_reader() + raw_index = (frame * self._num_planes * self._num_channels) + (plane * self._num_channels) + channel + with ScanImageTiffReader(str(self.file_path)) as io: + return io.data(beg=raw_index, end=raw_index + 1) + + def get_video( + self, start_frame=None, end_frame=None, channel: Optional[int] = 0, plane: Optional[int] = 0 + ) -> np.ndarray: + """Get the video frames. + + Parameters + ---------- + start_frame: int, optional + Start frame index (inclusive). + end_frame: int, optional + End frame index (exclusive). + channel: int, optional + Channel index. + plane: int, optional + Plane index. + + Returns + ------- + video: numpy.ndarray + The video frames. + """ + if start_frame is None: + start_frame = 0 + if end_frame is None: + end_frame = self._num_frames + self.check_frame_inputs(end_frame - 1, channel, plane) + ScanImageTiffReader = _get_scanimage_reader() + raw_start = (start_frame * self._num_planes * self._num_channels) + (plane * self._num_channels) + channel + raw_end = (end_frame * self._num_planes * self._num_channels) + (plane * self._num_channels) + channel + raw_end = np.min([raw_end, self._total_num_frames]) + with ScanImageTiffReader(filename=str(self.file_path)) as io: + raw_video = io.data(beg=raw_start, end=raw_end) + video = raw_video[channel :: self._num_channels] + video = video[plane :: self._num_planes] + return video + + def get_image_size(self) -> Tuple[int, int]: + return (self._num_rows, self._num_columns) + + def get_num_frames(self) -> int: + return self._num_frames + + def get_sampling_frequency(self) -> float: + return self._sampling_frequency + + def get_num_channels(self) -> int: + return self._num_channels + + def get_channel_names(self) -> list: + return self._channel_names + + def get_num_planes(self) -> int: + return self._num_planes + + def check_frame_inputs(self, frame, channel, plane) -> None: + if frame >= self._num_frames: + raise ValueError(f"Frame index ({frame}) exceeds number of frames ({self._num_frames}).") + if channel >= self._num_channels: + raise ValueError(f"Channel index ({channel}) exceeds number of channels ({self._num_channels}).") + if plane >= self._num_planes: + raise ValueError(f"Plane index ({plane}) exceeds number of planes ({self._num_planes}).") + + class ScanImageTiffImagingExtractor(ImagingExtractor): """Specialized extractor for reading TIFF files produced via ScanImage.""" diff --git a/tests/test_multiplane_scanimage.py b/tests/test_multiplane_scanimage.py new file mode 100644 index 00000000..786b9781 --- /dev/null +++ b/tests/test_multiplane_scanimage.py @@ -0,0 +1,29 @@ +from roiextractors.extractors.tiffimagingextractors.scanimagetiffimagingextractor import ( + ScanImageTiffMultiPlaneImagingExtractor, +) +import matplotlib.pyplot as plt + + +def main(): + file_path = "/Volumes/T7/CatalystNeuro/NWB/MouseV1/raw-tiffs/2ret/20230119_w57_1_2ret_00001.tif" + extractor = ScanImageTiffMultiPlaneImagingExtractor(file_path=file_path) + print(f"num_frames: {extractor.get_num_frames()}") + print(f"num_planes: {extractor.get_num_planes()}") + print(f"num_channels: {extractor.get_num_channels()}") + print(f"sampling_frequency: {extractor.get_sampling_frequency()}") + print(f"channel_names: {extractor.get_channel_names()}") + print(f"image_size: {extractor.get_image_size()}") + first_frame = extractor._get_single_frame(frame=0, channel=1, plane=2) + several_frames = extractor.get_frames(frame_idxs=[0, 10, 2], channel=1, plane=2) + video = extractor.get_video(channel=0, plane=2) + print(several_frames.shape) + plt.imshow(first_frame[0]) + plt.show() + plt.imshow(several_frames[0]) + plt.show() + plt.imshow(video[0]) + plt.show() + + +if __name__ == "__main__": + main() From a2e0caf4dd194d5645b52ed77b3543fd9147c93e Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Thu, 14 Sep 2023 14:28:07 -0700 Subject: [PATCH 02/47] switched extract_extra_metadata to local implementation to avoid neuroconv dependency --- .../scanimagetiffimagingextractor.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index 0e4a6fe2..3ca1e4f2 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -11,8 +11,6 @@ import numpy as np from pprint import pprint -from neuroconv.datainterfaces.ophys.scanimage.scanimageimaginginterface import extract_extra_metadata - from ...extraction_tools import PathType, FloatType, ArrayType, get_package from ...imagingextractor import ImagingExtractor @@ -24,6 +22,22 @@ def _get_scanimage_reader() -> type: ).ScanImageTiffReader +def extract_extra_metadata( + file_path, +) -> dict: # TODO: Refactor neuroconv to reference this implementation to avoid duplication + ScanImageTiffReader = _get_scanimage_reader() + io = ScanImageTiffReader(str(file_path)) + extra_metadata = {} + for metadata_string in (io.description(iframe=0), io.metadata()): + metadata_dict = { + x.split("=")[0].strip(): x.split("=")[1].strip() + for x in metadata_string.replace("\n", "\r").split("\r") + if "=" in x + } + extra_metadata = dict(**extra_metadata, **metadata_dict) + return extra_metadata + + class ScanImageTiffMultiPlaneImagingExtractor( ImagingExtractor ): # TODO: Refactor to avoid copy-paste from ScanImageTiffImagingExtractor From 882cf4c5985b1a7a324b5561bc3e52da9da6793e Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Thu, 14 Sep 2023 14:50:49 -0700 Subject: [PATCH 03/47] Refactored ScanImage to support multi-channel and multi-plane directly rather than using a separate class --- .../scanimagetiffimagingextractor.py | 136 +++--------------- tests/test_multiplane_scanimage.py | 4 +- 2 files changed, 25 insertions(+), 115 deletions(-) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index 3ca1e4f2..f134f53a 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -38,14 +38,31 @@ def extract_extra_metadata( return extra_metadata -class ScanImageTiffMultiPlaneImagingExtractor( - ImagingExtractor -): # TODO: Refactor to avoid copy-paste from ScanImageTiffImagingExtractor - extractor_name = "ScanImageTiffMultiPlaneImaging" +class ScanImageTiffImagingExtractor(ImagingExtractor): + """Specialized extractor for reading TIFF files produced via ScanImage.""" + + extractor_name = "ScanImageTiffImaging" is_writable = True mode = "file" def __init__(self, file_path: PathType) -> None: + """Create a ScanImageTiffImagingExtractor instance from a TIFF file produced by ScanImage. + + The underlying data is stored in a round-robin format collapsed into 3 dimensions (frames, rows, columns). + I.e. the first frame of each channel and each plane is stored, and then the second frame of each channel and + each plane, etc. + Ex. for 2 channels and 2 planes: + [channel_1_plane_1_frame_1, channel_2_plane_1_frame_1, channel_1_plane_2_frame_1, channel_2_plane_2_frame_1, + channel_1_plane_1_frame_2, channel_2_plane_1_frame_2, channel_1_plane_2_frame_2, channel_2_plane_2_frame_2, ... + channel_1_plane_1_frame_N, channel_2_plane_1_frame_N, channel_1_plane_2_frame_N, channel_2_plane_2_frame_N] + This file structure is sliced lazily using ScanImageTiffReader with the appropriate logic for specified + channels/frames. + + Parameters + ---------- + file_path : PathType + Path to the TIFF file. + """ super().__init__() self.file_path = Path(file_path) ScanImageTiffReader = _get_scanimage_reader() @@ -60,7 +77,8 @@ def __init__(self, file_path: PathType) -> None: "https://github.com/catalystneuro/roiextractors/issues " ) self._sampling_frequency = float(extra_metadata["SI.hRoiManager.scanVolumeRate"]) - # SI.hChannels.channelName = "{'channel_name_1' 'channel_name_2' ... 'channel_name_N'}" where N is the number of channels (active or not) + # SI.hChannels.channelName = "{'channel_name_1' 'channel_name_2' ... 'channel_name_N'}" + # where N is the number of channels (active or not) self._channel_names = extra_metadata["SI.hChannels.channelName"].split("'")[1::2][: self._num_channels] valid_suffixes = [".tiff", ".tif", ".TIFF", ".TIF"] @@ -193,111 +211,3 @@ def check_frame_inputs(self, frame, channel, plane) -> None: raise ValueError(f"Channel index ({channel}) exceeds number of channels ({self._num_channels}).") if plane >= self._num_planes: raise ValueError(f"Plane index ({plane}) exceeds number of planes ({self._num_planes}).") - - -class ScanImageTiffImagingExtractor(ImagingExtractor): - """Specialized extractor for reading TIFF files produced via ScanImage.""" - - extractor_name = "ScanImageTiffImaging" - is_writable = True - mode = "file" - - def __init__( - self, - file_path: PathType, - sampling_frequency: FloatType, - ): - """Create a ScanImageTiffImagingExtractor instance from a TIFF file produced by ScanImage. - - This extractor allows for lazy accessing of slices, unlike - :py:class:`~roiextractors.extractors.tiffimagingextractors.TiffImagingExtractor`. - However, direct slicing of the underlying data structure is not equivalent to a numpy memory map. - - Parameters - ---------- - file_path : PathType - Path to the TIFF file. - sampling_frequency : float - The frequency at which the frames were sampled, in Hz. - """ - ScanImageTiffReader = _get_scanimage_reader() - - super().__init__() - self.file_path = Path(file_path) - self._sampling_frequency = sampling_frequency - valid_suffixes = [".tiff", ".tif", ".TIFF", ".TIF"] - if self.file_path.suffix not in valid_suffixes: - suffix_string = ", ".join(valid_suffixes[:-1]) + f", or {valid_suffixes[-1]}" - warn( - f"Suffix ({self.file_path.suffix}) is not of type {suffix_string}! " - f"The {self.extractor_name}Extractor may not be appropriate for the file." - ) - - with ScanImageTiffReader(str(self.file_path)) as io: - shape = io.shape() # [frames, rows, columns] - if len(shape) == 3: - self._num_frames, self._num_rows, self._num_columns = shape - self._num_channels = 1 - else: # no example file for multiple color channels or depths - raise NotImplementedError( - "Extractor cannot handle 4D TIFF data. Please raise an issue to request this feature: " - "https://github.com/catalystneuro/roiextractors/issues " - ) - - def get_frames(self, frame_idxs: ArrayType, channel: int = 0) -> np.ndarray: - ScanImageTiffReader = _get_scanimage_reader() - - squeeze_data = False - if isinstance(frame_idxs, int): - squeeze_data = True - frame_idxs = [frame_idxs] - - if not all(np.diff(frame_idxs) == 1): - return np.concatenate([self._get_single_frame(idx=idx) for idx in frame_idxs]) - else: - with ScanImageTiffReader(filename=str(self.file_path)) as io: - frames = io.data(beg=frame_idxs[0], end=frame_idxs[-1] + 1) - if squeeze_data: - frames = frames.squeeze() - return frames - - # Data accessed through an open ScanImageTiffReader io gets scrambled if there are multiple calls. - # Thus, open fresh io in context each time something is needed. - def _get_single_frame(self, idx: int) -> np.ndarray: - """Get a single frame of data from the TIFF file. - - Parameters - ---------- - idx : int - The index of the frame to retrieve. - - Returns - ------- - frame: numpy.ndarray - The frame of data. - """ - ScanImageTiffReader = _get_scanimage_reader() - - with ScanImageTiffReader(str(self.file_path)) as io: - return io.data(beg=idx, end=idx + 1) - - def get_video(self, start_frame=None, end_frame=None, channel: Optional[int] = 0) -> np.ndarray: - ScanImageTiffReader = _get_scanimage_reader() - - with ScanImageTiffReader(filename=str(self.file_path)) as io: - return io.data(beg=start_frame, end=end_frame) - - def get_image_size(self) -> Tuple[int, int]: - return (self._num_rows, self._num_columns) - - def get_num_frames(self) -> int: - return self._num_frames - - def get_sampling_frequency(self) -> float: - return self._sampling_frequency - - def get_num_channels(self) -> int: - return self._num_channels - - def get_channel_names(self) -> list: - pass diff --git a/tests/test_multiplane_scanimage.py b/tests/test_multiplane_scanimage.py index 786b9781..a93f8410 100644 --- a/tests/test_multiplane_scanimage.py +++ b/tests/test_multiplane_scanimage.py @@ -1,12 +1,12 @@ from roiextractors.extractors.tiffimagingextractors.scanimagetiffimagingextractor import ( - ScanImageTiffMultiPlaneImagingExtractor, + ScanImageTiffImagingExtractor, ) import matplotlib.pyplot as plt def main(): file_path = "/Volumes/T7/CatalystNeuro/NWB/MouseV1/raw-tiffs/2ret/20230119_w57_1_2ret_00001.tif" - extractor = ScanImageTiffMultiPlaneImagingExtractor(file_path=file_path) + extractor = ScanImageTiffImagingExtractor(file_path=file_path) print(f"num_frames: {extractor.get_num_frames()}") print(f"num_planes: {extractor.get_num_planes()}") print(f"num_channels: {extractor.get_num_channels()}") From 8dd316b90ea0588c4246d7797656130ac4fc6d63 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Thu, 14 Sep 2023 14:53:43 -0700 Subject: [PATCH 04/47] Removed informal multiplane scanimage test --- tests/test_multiplane_scanimage.py | 29 ----------------------------- 1 file changed, 29 deletions(-) delete mode 100644 tests/test_multiplane_scanimage.py diff --git a/tests/test_multiplane_scanimage.py b/tests/test_multiplane_scanimage.py deleted file mode 100644 index a93f8410..00000000 --- a/tests/test_multiplane_scanimage.py +++ /dev/null @@ -1,29 +0,0 @@ -from roiextractors.extractors.tiffimagingextractors.scanimagetiffimagingextractor import ( - ScanImageTiffImagingExtractor, -) -import matplotlib.pyplot as plt - - -def main(): - file_path = "/Volumes/T7/CatalystNeuro/NWB/MouseV1/raw-tiffs/2ret/20230119_w57_1_2ret_00001.tif" - extractor = ScanImageTiffImagingExtractor(file_path=file_path) - print(f"num_frames: {extractor.get_num_frames()}") - print(f"num_planes: {extractor.get_num_planes()}") - print(f"num_channels: {extractor.get_num_channels()}") - print(f"sampling_frequency: {extractor.get_sampling_frequency()}") - print(f"channel_names: {extractor.get_channel_names()}") - print(f"image_size: {extractor.get_image_size()}") - first_frame = extractor._get_single_frame(frame=0, channel=1, plane=2) - several_frames = extractor.get_frames(frame_idxs=[0, 10, 2], channel=1, plane=2) - video = extractor.get_video(channel=0, plane=2) - print(several_frames.shape) - plt.imshow(first_frame[0]) - plt.show() - plt.imshow(several_frames[0]) - plt.show() - plt.imshow(video[0]) - plt.show() - - -if __name__ == "__main__": - main() From 53634a0c541f1b51e8a5454820061516489fc1ee Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Thu, 14 Sep 2023 17:01:39 -0700 Subject: [PATCH 05/47] Check that ScanImageReader works in CI tests --- tests/test_scan_image_tiff.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_scan_image_tiff.py b/tests/test_scan_image_tiff.py index a0d7c25d..0e577105 100644 --- a/tests/test_scan_image_tiff.py +++ b/tests/test_scan_image_tiff.py @@ -17,9 +17,9 @@ class TestScanImageTiffExtractor(TestCase): def setUpClass(cls): cls.file_path = OPHYS_DATA_PATH / "imaging_datasets" / "Tif" / "sample_scanimage.tiff" cls.tmpdir = Path(mkdtemp()) - cls.imaging_extractor = ScanImageTiffImagingExtractor(file_path=cls.file_path, sampling_frequency=30.0) with ScanImageTiffReader(filename=str(cls.imaging_extractor.file_path)) as io: cls.data = io.data() + cls.imaging_extractor = ScanImageTiffImagingExtractor(file_path=cls.file_path) @classmethod def tearDownClass(cls): From ce69ecdd763ca04bdf8d795683237869533ea2a9 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Thu, 14 Sep 2023 17:05:06 -0700 Subject: [PATCH 06/47] Check that ScanImageReader works in CI tests --- tests/test_scan_image_tiff.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_scan_image_tiff.py b/tests/test_scan_image_tiff.py index 0e577105..7f2262e6 100644 --- a/tests/test_scan_image_tiff.py +++ b/tests/test_scan_image_tiff.py @@ -17,9 +17,11 @@ class TestScanImageTiffExtractor(TestCase): def setUpClass(cls): cls.file_path = OPHYS_DATA_PATH / "imaging_datasets" / "Tif" / "sample_scanimage.tiff" cls.tmpdir = Path(mkdtemp()) - with ScanImageTiffReader(filename=str(cls.imaging_extractor.file_path)) as io: + with ScanImageTiffReader(filename=str(cls.file_path)) as io: cls.data = io.data() cls.imaging_extractor = ScanImageTiffImagingExtractor(file_path=cls.file_path) + with ScanImageTiffReader(filename=str(cls.imaging_extractor.file_path)) as io: + cls.data = io.data() @classmethod def tearDownClass(cls): From 5b8fe3c47c973545429468a9c4be3a40e660362f Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Fri, 15 Sep 2023 12:45:16 -0700 Subject: [PATCH 07/47] fixed local tests and removed unnecessary io check --- tests/test_scan_image_tiff.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_scan_image_tiff.py b/tests/test_scan_image_tiff.py index 7f2262e6..ded5f759 100644 --- a/tests/test_scan_image_tiff.py +++ b/tests/test_scan_image_tiff.py @@ -17,8 +17,6 @@ class TestScanImageTiffExtractor(TestCase): def setUpClass(cls): cls.file_path = OPHYS_DATA_PATH / "imaging_datasets" / "Tif" / "sample_scanimage.tiff" cls.tmpdir = Path(mkdtemp()) - with ScanImageTiffReader(filename=str(cls.file_path)) as io: - cls.data = io.data() cls.imaging_extractor = ScanImageTiffImagingExtractor(file_path=cls.file_path) with ScanImageTiffReader(filename=str(cls.imaging_extractor.file_path)) as io: cls.data = io.data() From be21dd77f3f7977ad4a43d6394096eae9ab8cf67 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Fri, 15 Sep 2023 13:15:36 -0700 Subject: [PATCH 08/47] setup informal test for newer ScanImageData --- tests/temp_test_scanimage.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 tests/temp_test_scanimage.py diff --git a/tests/temp_test_scanimage.py b/tests/temp_test_scanimage.py new file mode 100644 index 00000000..16cc2c81 --- /dev/null +++ b/tests/temp_test_scanimage.py @@ -0,0 +1,22 @@ +# scratch file to test scanimage tiff extractor +from roiextractors import ScanImageTiffImagingExtractor + + +def main(): + example_holo = "/Volumes/T7/CatalystNeuro/NWB/MouseV1/raw-tiffs/2ret/20230119_w57_1_2ret_00001.tif" + example_single = "/Users/pauladkisson/Documents/CatalystNeuro/ROIExtractors/ophys_testing_data/imaging_datasets/ScanImage/scanimage_20220801_single.tif" + example_volume = "/Users/pauladkisson/Documents/CatalystNeuro/ROIExtractors/ophys_testing_data/imaging_datasets/ScanImage/scanimage_20220801_volume.tif" + example_multivolume = "/Users/pauladkisson/Documents/CatalystNeuro/ROIExtractors/ophys_testing_data/imaging_datasets/ScanImage/scanimage_20220801_multivolume.tif" + + extractor = ScanImageTiffImagingExtractor(file_path=example_holo) + print("Example holographic file loads!") + extractor = ScanImageTiffImagingExtractor(file_path=example_single) + print("Example single-plane file loads!") + extractor = ScanImageTiffImagingExtractor(file_path=example_volume) + print("Example volume file loads!") + extractor = ScanImageTiffImagingExtractor(file_path=example_multivolume) + print("Example multivolume file loads!") + + +if __name__ == "__main__": + main() From 4819be2526d3cb5d092327e88f03d5ba5a095547 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Fri, 15 Sep 2023 15:57:01 -0700 Subject: [PATCH 09/47] refactored init to manually accept args rather than automatically parsing from metadata to support old testing file --- .../scanimagetiffimagingextractor.py | 60 +++++++++++++++---- tests/temp_test_scanimage.py | 30 ++++++++-- tests/test_scan_image_tiff.py | 2 +- 3 files changed, 76 insertions(+), 16 deletions(-) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index f134f53a..963867e4 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -38,6 +38,30 @@ def extract_extra_metadata( return extra_metadata +def parse_metadata(metadata): + """Parse metadata dictionary to extract relevant information. + + Notes + ----- + SI.hChannels.channelsActive = '[1;2;...;N]' where N is the number of active channels. + SI.hChannels.channelName = "{'channel_name_1' 'channel_name_2' ... 'channel_name_M'}" + where M is the number of channels (active or not). + """ + sampling_frequency = float(metadata["SI.hRoiManager.scanVolumeRate"]) + num_channels = len(metadata["SI.hChannels.channelsActive"].split(";")) + num_planes = int(metadata["SI.hStackManager.numSlices"]) + frames_per_slice = int(metadata["SI.hStackManager.framesPerSlice"]) + channel_names = metadata["SI.hChannels.channelName"].split("'")[1::2][:num_channels] + metadata_parsed = dict( + sampling_frequency=sampling_frequency, + num_channels=num_channels, + num_planes=num_planes, + frames_per_slice=frames_per_slice, + channel_names=channel_names, + ) + return metadata_parsed + + class ScanImageTiffImagingExtractor(ImagingExtractor): """Specialized extractor for reading TIFF files produced via ScanImage.""" @@ -45,7 +69,15 @@ class ScanImageTiffImagingExtractor(ImagingExtractor): is_writable = True mode = "file" - def __init__(self, file_path: PathType) -> None: + def __init__( + self, + file_path: PathType, + sampling_frequency: float, + num_channels: Optional[int] = 1, + num_planes: Optional[int] = 1, + frames_per_slice: Optional[int] = 1, + channel_names: Optional[list] = None, + ) -> None: """Create a ScanImageTiffImagingExtractor instance from a TIFF file produced by ScanImage. The underlying data is stored in a round-robin format collapsed into 3 dimensions (frames, rows, columns). @@ -62,24 +94,29 @@ def __init__(self, file_path: PathType) -> None: ---------- file_path : PathType Path to the TIFF file. + sampling_frequency : float + Sampling frequency of each plane (scanVolumeRate) in Hz. + num_channels : int, optional + Number of active channels that were acquired (default=1). + num_planes : int, optional + Number of depth planes that were acquired (default=1). + frames_per_slice : int, optional + Number of frames per depth plane that were acquired (default=1). + channel_names : list, optional + Names of the channels that were acquired (default=None). """ super().__init__() self.file_path = Path(file_path) - ScanImageTiffReader = _get_scanimage_reader() - extra_metadata = extract_extra_metadata(file_path) - # SI.hChannels.channelsActive = '[1;2;...;N]' where N is the number of active channels - self._num_channels = len(extra_metadata["SI.hChannels.channelsActive"].split(";")) - self._num_planes = int(extra_metadata["SI.hStackManager.numSlices"]) - frames_per_slice = int(extra_metadata["SI.hStackManager.framesPerSlice"]) + self._sampling_frequency = sampling_frequency + self.metadata = extract_extra_metadata(file_path) + self._num_channels = num_channels + self._num_planes = num_planes if frames_per_slice != 1: raise NotImplementedError( "Extractor cannot handle multiple frames per slice. Please raise an issue to request this feature: " "https://github.com/catalystneuro/roiextractors/issues " ) - self._sampling_frequency = float(extra_metadata["SI.hRoiManager.scanVolumeRate"]) - # SI.hChannels.channelName = "{'channel_name_1' 'channel_name_2' ... 'channel_name_N'}" - # where N is the number of channels (active or not) - self._channel_names = extra_metadata["SI.hChannels.channelName"].split("'")[1::2][: self._num_channels] + self._channel_names = channel_names valid_suffixes = [".tiff", ".tif", ".TIFF", ".TIF"] if self.file_path.suffix not in valid_suffixes: @@ -88,6 +125,7 @@ def __init__(self, file_path: PathType) -> None: f"Suffix ({self.file_path.suffix}) is not of type {suffix_string}! " f"The {self.extractor_name}Extractor may not be appropriate for the file." ) + ScanImageTiffReader = _get_scanimage_reader() with ScanImageTiffReader(str(self.file_path)) as io: shape = io.shape() # [frames, rows, columns] if len(shape) == 3: diff --git a/tests/temp_test_scanimage.py b/tests/temp_test_scanimage.py index 16cc2c81..81e2e4f3 100644 --- a/tests/temp_test_scanimage.py +++ b/tests/temp_test_scanimage.py @@ -1,20 +1,42 @@ # scratch file to test scanimage tiff extractor from roiextractors import ScanImageTiffImagingExtractor +from roiextractors.extractors.tiffimagingextractors.scanimagetiffimagingextractor import ( + extract_extra_metadata, + parse_metadata, +) def main(): + example_test = "/Users/pauladkisson/Documents/CatalystNeuro/ROIExtractors/ophys_testing_data/imaging_datasets/ScanImage/sample_scanimage_version_3_8.tiff" example_holo = "/Volumes/T7/CatalystNeuro/NWB/MouseV1/raw-tiffs/2ret/20230119_w57_1_2ret_00001.tif" example_single = "/Users/pauladkisson/Documents/CatalystNeuro/ROIExtractors/ophys_testing_data/imaging_datasets/ScanImage/scanimage_20220801_single.tif" example_volume = "/Users/pauladkisson/Documents/CatalystNeuro/ROIExtractors/ophys_testing_data/imaging_datasets/ScanImage/scanimage_20220801_volume.tif" example_multivolume = "/Users/pauladkisson/Documents/CatalystNeuro/ROIExtractors/ophys_testing_data/imaging_datasets/ScanImage/scanimage_20220801_multivolume.tif" - extractor = ScanImageTiffImagingExtractor(file_path=example_holo) + extractor = ScanImageTiffImagingExtractor(file_path=example_test, sampling_frequency=30.0) + print("Example test file loads!") + + metadata = extract_extra_metadata(example_holo) + metadata_parsed = parse_metadata(metadata) + extractor = ScanImageTiffImagingExtractor(file_path=example_holo, **metadata_parsed) print("Example holographic file loads!") - extractor = ScanImageTiffImagingExtractor(file_path=example_single) + + metadata = extract_extra_metadata(example_single) + metadata_parsed = parse_metadata(metadata) + metadata_parsed["frames_per_slice"] = 1 # Multiple frames per slice is not supported yet + extractor = ScanImageTiffImagingExtractor(file_path=example_single, **metadata_parsed) print("Example single-plane file loads!") - extractor = ScanImageTiffImagingExtractor(file_path=example_volume) + + metadata = extract_extra_metadata(example_volume) + metadata_parsed = parse_metadata(metadata) + metadata_parsed["frames_per_slice"] = 1 # Multiple frames per slice is not supported yet + extractor = ScanImageTiffImagingExtractor(file_path=example_volume, **metadata_parsed) print("Example volume file loads!") - extractor = ScanImageTiffImagingExtractor(file_path=example_multivolume) + + metadata = extract_extra_metadata(example_multivolume) + metadata_parsed = parse_metadata(metadata) + metadata_parsed["frames_per_slice"] = 1 # Multiple frames per slice is not supported yet + extractor = ScanImageTiffImagingExtractor(file_path=example_multivolume, **metadata_parsed) print("Example multivolume file loads!") diff --git a/tests/test_scan_image_tiff.py b/tests/test_scan_image_tiff.py index ded5f759..a0d7c25d 100644 --- a/tests/test_scan_image_tiff.py +++ b/tests/test_scan_image_tiff.py @@ -17,7 +17,7 @@ class TestScanImageTiffExtractor(TestCase): def setUpClass(cls): cls.file_path = OPHYS_DATA_PATH / "imaging_datasets" / "Tif" / "sample_scanimage.tiff" cls.tmpdir = Path(mkdtemp()) - cls.imaging_extractor = ScanImageTiffImagingExtractor(file_path=cls.file_path) + cls.imaging_extractor = ScanImageTiffImagingExtractor(file_path=cls.file_path, sampling_frequency=30.0) with ScanImageTiffReader(filename=str(cls.imaging_extractor.file_path)) as io: cls.data = io.data() From e84b6ad974a60621365d979f070d59ea82d4e242 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Mon, 18 Sep 2023 13:15:30 -0700 Subject: [PATCH 10/47] refactored to take plane and channel at __init__ rather than in get_video --- .../scanimagetiffimagingextractor.py | 85 ++++++++++++------- 1 file changed, 52 insertions(+), 33 deletions(-) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index 963867e4..1954a9ba 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -73,7 +73,9 @@ def __init__( self, file_path: PathType, sampling_frequency: float, + channel: Optional[int] = 0, num_channels: Optional[int] = 1, + plane: Optional[int] = 0, num_planes: Optional[int] = 1, frames_per_slice: Optional[int] = 1, channel_names: Optional[list] = None, @@ -96,27 +98,36 @@ def __init__( Path to the TIFF file. sampling_frequency : float Sampling frequency of each plane (scanVolumeRate) in Hz. + channel : int, optional + Index of the optical channel for this extractor (default=0). num_channels : int, optional Number of active channels that were acquired (default=1). + plane : int, optional + Index of the depth plane for this extractor (default=0). num_planes : int, optional Number of depth planes that were acquired (default=1). frames_per_slice : int, optional Number of frames per depth plane that were acquired (default=1). channel_names : list, optional - Names of the channels that were acquired (default=None). + Names of the channels (default=None). """ super().__init__() self.file_path = Path(file_path) self._sampling_frequency = sampling_frequency self.metadata = extract_extra_metadata(file_path) + self.channel = channel self._num_channels = num_channels + self.plane = plane self._num_planes = num_planes + if channel >= num_channels: + raise ValueError(f"Channel index ({channel}) exceeds number of channels ({num_channels}).") + if plane >= num_planes: + raise ValueError(f"Plane index ({plane}) exceeds number of planes ({num_planes}).") if frames_per_slice != 1: raise NotImplementedError( "Extractor cannot handle multiple frames per slice. Please raise an issue to request this feature: " "https://github.com/catalystneuro/roiextractors/issues " ) - self._channel_names = channel_names valid_suffixes = [".tiff", ".tif", ".TIFF", ".TIF"] if self.file_path.suffix not in valid_suffixes: @@ -137,60 +148,50 @@ def __init__( "https://github.com/catalystneuro/roiextractors/issues " ) - def get_frames(self, frame_idxs: ArrayType, channel: int = 0, plane: int = 0) -> np.ndarray: + def get_frames(self, frame_idxs: ArrayType) -> np.ndarray: """Get specific video frames from indices (not necessarily continuous). Parameters ---------- frame_idxs: array-like Indices of frames to return. - channel: int, optional - Channel index. - plane: int, optional - Plane index. Returns ------- frames: numpy.ndarray The video frames. """ - self.check_frame_inputs(frame_idxs[-1], channel, plane) + self.check_frame_inputs(frame_idxs[-1]) if isinstance(frame_idxs, int): frame_idxs = [frame_idxs] if not all(np.diff(frame_idxs) == 1): return np.concatenate([self._get_single_frame(frame=idx) for idx in frame_idxs]) else: - return self.get_video(start_frame=frame_idxs[0], end_frame=frame_idxs[-1] + 1, channel=channel) + return self.get_video(start_frame=frame_idxs[0], end_frame=frame_idxs[-1] + 1) # Data accessed through an open ScanImageTiffReader io gets scrambled if there are multiple calls. # Thus, open fresh io in context each time something is needed. - def _get_single_frame(self, frame: int, channel: Optional[int] = 0, plane: Optional[int] = 0) -> np.ndarray: + def _get_single_frame(self, frame: int) -> np.ndarray: """Get a single frame of data from the TIFF file. Parameters ---------- frame : int The index of the frame to retrieve. - channel : int, optional - The index of the channel to retrieve. - plane : int, optional - The index of the plane to retrieve. Returns ------- frame: numpy.ndarray The frame of data. """ - self.check_frame_inputs(frame, channel, plane) + self.check_frame_inputs(frame) ScanImageTiffReader = _get_scanimage_reader() - raw_index = (frame * self._num_planes * self._num_channels) + (plane * self._num_channels) + channel + raw_index = self.frame_to_raw_index(frame) with ScanImageTiffReader(str(self.file_path)) as io: return io.data(beg=raw_index, end=raw_index + 1) - def get_video( - self, start_frame=None, end_frame=None, channel: Optional[int] = 0, plane: Optional[int] = 0 - ) -> np.ndarray: + def get_video(self, start_frame=None, end_frame=None) -> np.ndarray: """Get the video frames. Parameters @@ -199,10 +200,6 @@ def get_video( Start frame index (inclusive). end_frame: int, optional End frame index (exclusive). - channel: int, optional - Channel index. - plane: int, optional - Plane index. Returns ------- @@ -213,15 +210,15 @@ def get_video( start_frame = 0 if end_frame is None: end_frame = self._num_frames - self.check_frame_inputs(end_frame - 1, channel, plane) + self.check_frame_inputs(end_frame - 1) ScanImageTiffReader = _get_scanimage_reader() - raw_start = (start_frame * self._num_planes * self._num_channels) + (plane * self._num_channels) + channel - raw_end = (end_frame * self._num_planes * self._num_channels) + (plane * self._num_channels) + channel + raw_start = self.frame_to_raw_index(start_frame) + raw_end = self.frame_to_raw_index(end_frame) raw_end = np.min([raw_end, self._total_num_frames]) with ScanImageTiffReader(filename=str(self.file_path)) as io: raw_video = io.data(beg=raw_start, end=raw_end) - video = raw_video[channel :: self._num_channels] - video = video[plane :: self._num_planes] + video = raw_video[self.channel :: self._num_channels] + video = video[self.plane :: self._num_planes] return video def get_image_size(self) -> Tuple[int, int]: @@ -242,10 +239,32 @@ def get_channel_names(self) -> list: def get_num_planes(self) -> int: return self._num_planes - def check_frame_inputs(self, frame, channel, plane) -> None: + def check_frame_inputs(self, frame) -> None: if frame >= self._num_frames: raise ValueError(f"Frame index ({frame}) exceeds number of frames ({self._num_frames}).") - if channel >= self._num_channels: - raise ValueError(f"Channel index ({channel}) exceeds number of channels ({self._num_channels}).") - if plane >= self._num_planes: - raise ValueError(f"Plane index ({plane}) exceeds number of planes ({self._num_planes}).") + + def frame_to_raw_index(self, frame): + """Convert a frame index to the raw index in the TIFF file. + + Parameters + ---------- + frame : int + The index of the frame to retrieve. + + Returns + ------- + raw_index: int + The raw index of the frame in the TIFF file. + + Notes + ----- + The underlying data is stored in a round-robin format collapsed into 3 dimensions (frames, rows, columns). + I.e. the first frame of each channel and each plane is stored, and then the second frame of each channel and + each plane, etc. + Ex. for 2 channels and 2 planes: + [channel_1_plane_1_frame_1, channel_2_plane_1_frame_1, channel_1_plane_2_frame_1, channel_2_plane_2_frame_1, + channel_1_plane_1_frame_2, channel_2_plane_1_frame_2, channel_1_plane_2_frame_2, channel_2_plane_2_frame_2, ... + channel_1_plane_1_frame_N, channel_2_plane_1_frame_N, channel_1_plane_2_frame_N, channel_2_plane_2_frame_N] + """ + raw_index = (frame * self._num_planes * self._num_channels) + (self.plane * self._num_channels) + self.channel + return raw_index From 243c16883f70a4a64ff29bbbfc93d151a2dcde19 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Mon, 18 Sep 2023 13:16:56 -0700 Subject: [PATCH 11/47] refactored to take plane and channel at __init__ rather than in get_video -- added channel_names --- .../tiffimagingextractors/scanimagetiffimagingextractor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index 1954a9ba..134c9ba0 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -119,6 +119,7 @@ def __init__( self._num_channels = num_channels self.plane = plane self._num_planes = num_planes + self._channel_names = channel_names if channel >= num_channels: raise ValueError(f"Channel index ({channel}) exceeds number of channels ({num_channels}).") if plane >= num_planes: From 8e407e8fb573e570ecb92f2d6329e308f3d2409c Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Mon, 18 Sep 2023 15:50:45 -0700 Subject: [PATCH 12/47] added multi-plane extractor --- .../scanimagetiffimagingextractor.py | 136 +++++++++++++++++- tests/temp_test_scanimage.py | 3 +- 2 files changed, 135 insertions(+), 4 deletions(-) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index 134c9ba0..bf0014f2 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -6,7 +6,7 @@ Specialized extractor for reading TIFF files produced via ScanImage. """ from pathlib import Path -from typing import Optional, Tuple +from typing import Optional, Tuple, List from warnings import warn import numpy as np from pprint import pprint @@ -62,6 +62,137 @@ def parse_metadata(metadata): return metadata_parsed +class ScanImageTiffMultiPlaneImagingExtractor(ImagingExtractor): + """Specialized extractor for reading multi-plane (volumetric) TIFF files produced via ScanImage.""" + + extractor_name = "ScanImageTiffMultiPlaneImaging" + is_writable = True + mode = "file" + + def __init__( + self, + file_path: PathType, + sampling_frequency: float, + channel: Optional[int] = 0, + num_channels: Optional[int] = 1, + num_planes: Optional[int] = 1, + frames_per_slice: Optional[int] = 1, + channel_names: Optional[list] = None, + ) -> None: + super().__init__() + self.file_path = Path(file_path) + self._sampling_frequency = sampling_frequency + self.metadata = extract_extra_metadata(file_path) + self.channel = channel + self._num_channels = num_channels + self._num_planes = num_planes + self._channel_names = channel_names + if channel >= num_channels: + raise ValueError(f"Channel index ({channel}) exceeds number of channels ({num_channels}).") + if frames_per_slice != 1: + raise NotImplementedError( + "Extractor cannot handle multiple frames per slice. Please raise an issue to request this feature: " + "https://github.com/catalystneuro/roiextractors/issues " + ) + imaging_extractors = [] + for plane in range(num_planes): + imaging_extractor = ScanImageTiffImagingExtractor( + file_path=file_path, + sampling_frequency=sampling_frequency, + channel=channel, + num_channels=num_channels, + plane=plane, + num_planes=num_planes, + channel_names=channel_names, + ) + imaging_extractors.append(imaging_extractor) + assert isinstance(imaging_extractors, list), "Enter a list of ImagingExtractor objects as argument" + assert all(isinstance(imaging_extractor, ImagingExtractor) for imaging_extractor in imaging_extractors) + # self._check_consistency_between_imaging_extractors(imaging_extractors) + self._num_planes = len(imaging_extractors) + assert all( + imaging_extractor.get_num_planes() == self._num_planes for imaging_extractor in imaging_extractors + ), "All imaging extractors must have the same number of planes." + self._imaging_extractors = imaging_extractors + + def get_video(self, start_frame: Optional[int] = None, end_frame: Optional[int] = None) -> np.ndarray: + """Get the video frames. + + Parameters + ---------- + start_frame: int, optional + Start frame index (inclusive). + end_frame: int, optional + End frame index (exclusive). + + Returns + ------- + video: numpy.ndarray + The 3D video frames (num_rows, num_columns, num_planes). + """ + start_frame = start_frame if start_frame is not None else 0 + end_frame = end_frame if end_frame is not None else self.get_num_frames() + + video = np.zeros((end_frame - start_frame, *self.get_image_size(), self.get_num_planes()), self.get_dtype()) + for i, imaging_extractor in enumerate(self._imaging_extractors): + video[..., i] = imaging_extractor.get_video(start_frame, end_frame) + return video + + def get_frames(self, frame_idxs: ArrayType) -> np.ndarray: + """Get specific video frames from indices (not necessarily continuous). + + Parameters + ---------- + frame_idxs: array-like + Indices of frames to return. + + Returns + ------- + frames: numpy.ndarray + The 3D video frames (num_rows, num_columns, num_planes). + """ + if isinstance(frame_idxs, int): + frame_idxs = [frame_idxs] + + if not all(np.diff(frame_idxs) == 1): + return np.concatenate([self._get_single_frame(frame=idx) for idx in frame_idxs]) + else: + return self.get_video(start_frame=frame_idxs[0], end_frame=frame_idxs[-1] + 1) + + def _get_single_frame(self, frame: int) -> np.ndarray: + """Get a single frame of data from the TIFF file. + + Parameters + ---------- + frame : int + The index of the frame to retrieve. + + Returns + ------- + frame: numpy.ndarray + The 3D frame of data (num_rows, num_columns, num_planes). + """ + return self.get_video(start_frame=frame, end_frame=frame + 1)[0] + + def get_image_size(self) -> Tuple: + return self._imaging_extractors[0].get_image_size() + + def get_num_planes(self) -> int: + return self._num_planes + + def get_num_frames(self) -> int: + return self._num_frames + + def get_sampling_frequency(self) -> float: + return self._imaging_extractors[0].get_sampling_frequency() + + def get_channel_names(self) -> list: + return self._imaging_extractors[0].get_channel_names() + + def get_num_channels(self) -> int: + return self._imaging_extractors[0].get_num_channels() + + class ScanImageTiffImagingExtractor(ImagingExtractor): """Specialized extractor for reading TIFF files produced via ScanImage.""" @@ -89,8 +220,7 @@ def __init__( [channel_1_plane_1_frame_1, channel_2_plane_1_frame_1, channel_1_plane_2_frame_1, channel_2_plane_2_frame_1, channel_1_plane_1_frame_2, channel_2_plane_1_frame_2, channel_1_plane_2_frame_2, channel_2_plane_2_frame_2, ... channel_1_plane_1_frame_N, channel_2_plane_1_frame_N, channel_1_plane_2_frame_N, channel_2_plane_2_frame_N] - This file structure is sliced lazily using ScanImageTiffReader with the appropriate logic for specified - channels/frames. + This file structured is accessed by ScanImageTiffImagingExtractor for a single channel and plane. Parameters ---------- diff --git a/tests/temp_test_scanimage.py b/tests/temp_test_scanimage.py index 81e2e4f3..1bc9580c 100644 --- a/tests/temp_test_scanimage.py +++ b/tests/temp_test_scanimage.py @@ -3,6 +3,7 @@ from roiextractors.extractors.tiffimagingextractors.scanimagetiffimagingextractor import ( extract_extra_metadata, parse_metadata, + ScanImageTiffMultiPlaneImagingExtractor, ) @@ -18,7 +19,7 @@ def main(): metadata = extract_extra_metadata(example_holo) metadata_parsed = parse_metadata(metadata) - extractor = ScanImageTiffImagingExtractor(file_path=example_holo, **metadata_parsed) + extractor = ScanImageTiffMultiPlaneImagingExtractor(file_path=example_holo, **metadata_parsed) print("Example holographic file loads!") metadata = extract_extra_metadata(example_single) From b04baabe645248859465c7b80630e24832614b62 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Mon, 18 Sep 2023 17:18:58 -0700 Subject: [PATCH 13/47] refactored out generic MultiPlaneImagingExtractor into its own class --- .../scanimagetiffimagingextractor.py | 180 +++++++++++------- 1 file changed, 116 insertions(+), 64 deletions(-) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index bf0014f2..2a7f6a1b 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -6,12 +6,14 @@ Specialized extractor for reading TIFF files produced via ScanImage. """ from pathlib import Path -from typing import Optional, Tuple, List +from typing import Optional, Tuple, List, Iterable from warnings import warn import numpy as np from pprint import pprint -from ...extraction_tools import PathType, FloatType, ArrayType, get_package +from roiextractors.extraction_tools import DtypeType + +from ...extraction_tools import PathType, FloatType, ArrayType, DtypeType, get_package from ...imagingextractor import ImagingExtractor @@ -62,58 +64,63 @@ def parse_metadata(metadata): return metadata_parsed -class ScanImageTiffMultiPlaneImagingExtractor(ImagingExtractor): - """Specialized extractor for reading multi-plane (volumetric) TIFF files produced via ScanImage.""" +class MultiPlaneImagingExtractor(ImagingExtractor): + """Class to combine multiple ImagingExtractor objects by depth plane.""" - extractor_name = "ScanImageTiffMultiPlaneImaging" - is_writable = True - mode = "file" + extractor_name = "MultiPlaneImaging" + installed = True + installatiuon_mesage = "" - def __init__( - self, - file_path: PathType, - sampling_frequency: float, - channel: Optional[int] = 0, - num_channels: Optional[int] = 1, - num_planes: Optional[int] = 1, - frames_per_slice: Optional[int] = 1, - channel_names: Optional[list] = None, - ) -> None: + def __init__(self, imaging_extractors: List[ImagingExtractor]): + """Initialize a MultiPlaneImagingExtractor object from a list of ImagingExtractors. + + Parameters + ---------- + imaging_extractors: list of ImagingExtractor + list of imaging extractor objects + """ super().__init__() - self.file_path = Path(file_path) - self._sampling_frequency = sampling_frequency - self.metadata = extract_extra_metadata(file_path) - self.channel = channel - self._num_channels = num_channels - self._num_planes = num_planes - self._channel_names = channel_names - if channel >= num_channels: - raise ValueError(f"Channel index ({channel}) exceeds number of channels ({num_channels}).") - if frames_per_slice != 1: - raise NotImplementedError( - "Extractor cannot handle multiple frames per slice. Please raise an issue to request this feature: " - "https://github.com/catalystneuro/roiextractors/issues " - ) - imaging_extractors = [] - for plane in range(num_planes): - imaging_extractor = ScanImageTiffImagingExtractor( - file_path=file_path, - sampling_frequency=sampling_frequency, - channel=channel, - num_channels=num_channels, - plane=plane, - num_planes=num_planes, - channel_names=channel_names, - ) - imaging_extractors.append(imaging_extractor) assert isinstance(imaging_extractors, list), "Enter a list of ImagingExtractor objects as argument" assert all(isinstance(imaging_extractor, ImagingExtractor) for imaging_extractor in imaging_extractors) - # self._check_consistency_between_imaging_extractors(imaging_extractors) - self._num_planes = len(imaging_extractors) - assert all( - imaging_extractor.get_num_planes() == self._num_planes for imaging_extractor in imaging_extractors - ), "All imaging extractors must have the same number of planes." + self._check_consistency_between_imaging_extractors(imaging_extractors) self._imaging_extractors = imaging_extractors + self._num_planes = len(imaging_extractors) + + def _check_consistency_between_imaging_extractors(self, imaging_extractors: List[ImagingExtractor]): + """Check that essential properties are consistent between extractors so that they can be combined appropriately. + + Parameters + ---------- + imaging_extractors: list of ImagingExtractor + list of imaging extractor objects + + Raises + ------ + AssertionError + If any of the properties are not consistent between extractors. + + Notes + ----- + This method checks the following properties: + - sampling frequency + - image size + - number of channels + - channel names + - data type + """ + properties_to_check = dict( + get_sampling_frequency="The sampling frequency", + get_image_size="The size of a frame", + get_num_channels="The number of channels", + get_channel_names="The name of the channels", + get_dtype="The data type.", + ) + for method, property_message in properties_to_check.items(): + values = [getattr(extractor, method)() for extractor in imaging_extractors] + unique_values = set(tuple(v) if isinstance(v, Iterable) else v for v in values) + assert ( + len(unique_values) == 1 + ), f"{property_message} is not consistent over the files (found {unique_values})." def get_video(self, start_frame: Optional[int] = None, end_frame: Optional[int] = None) -> np.ndarray: """Get the video frames. @@ -155,29 +162,23 @@ def get_frames(self, frame_idxs: ArrayType) -> np.ndarray: frame_idxs = [frame_idxs] if not all(np.diff(frame_idxs) == 1): - return np.concatenate([self._get_single_frame(frame=idx) for idx in frame_idxs]) + frames = np.zeros((len(frame_idxs), *self.get_image_size(), self.get_num_planes()), self.get_dtype()) + for i, imaging_extractor in enumerate(self._imaging_extractors): + frames[..., i] = imaging_extractor.get_frames(frame_idxs) else: return self.get_video(start_frame=frame_idxs[0], end_frame=frame_idxs[-1] + 1) - def _get_single_frame(self, frame: int) -> np.ndarray: - """Get a single frame of data from the TIFF file. + def get_image_size(self) -> Tuple: + return self._imaging_extractors[0].get_image_size() - Parameters - ---------- - frame : int - The index of the frame to retrieve. + def get_num_planes(self) -> int: + """Get the number of depth planes. Returns ------- - frame: numpy.ndarray - The 3D frame of data (num_rows, num_columns, num_planes). + _num_planes: int + The number of depth planes. """ - return self.get_video(start_frame=frame, end_frame=frame + 1)[0] - - def get_image_size(self) -> Tuple: - return self._imaging_extractors[0].get_image_size() - - def get_num_planes(self) -> int: return self._num_planes def get_num_frames(self) -> int: @@ -192,6 +193,54 @@ def get_channel_names(self) -> list: def get_num_channels(self) -> int: return self._imaging_extractors[0].get_num_channels() + def get_dtype(self) -> DtypeType: + return self._imaging_extractors[0].get_dtype() + + +class ScanImageTiffMultiPlaneImagingExtractor(MultiPlaneImagingExtractor): + """Specialized extractor for reading multi-plane (volumetric) TIFF files produced via ScanImage.""" + + extractor_name = "ScanImageTiffMultiPlaneImaging" + is_writable = True + mode = "file" + + def __init__( + self, + file_path: PathType, + sampling_frequency: float, + channel: Optional[int] = 0, + num_channels: Optional[int] = 1, + num_planes: Optional[int] = 1, + frames_per_slice: Optional[int] = 1, + channel_names: Optional[list] = None, + ) -> None: + self.file_path = Path(file_path) + self.metadata = extract_extra_metadata(file_path) + self.channel = channel + if channel >= num_channels: + raise ValueError(f"Channel index ({channel}) exceeds number of channels ({num_channels}).") + if frames_per_slice != 1: + raise NotImplementedError( + "Extractor cannot handle multiple frames per slice. Please raise an issue to request this feature: " + "https://github.com/catalystneuro/roiextractors/issues " + ) + imaging_extractors = [] + for plane in range(num_planes): + imaging_extractor = ScanImageTiffImagingExtractor( + file_path=file_path, + sampling_frequency=sampling_frequency, + channel=channel, + num_channels=num_channels, + plane=plane, + num_planes=num_planes, + channel_names=channel_names, + ) + imaging_extractors.append(imaging_extractor) + super().__init__(imaging_extractors=imaging_extractors) + assert all( + imaging_extractor.get_num_planes() == self._num_planes for imaging_extractor in imaging_extractors + ), "All imaging extractors must have the same number of planes." + class ScanImageTiffImagingExtractor(ImagingExtractor): """Specialized extractor for reading TIFF files produced via ScanImage.""" @@ -292,9 +341,9 @@ def get_frames(self, frame_idxs: ArrayType) -> np.ndarray: frames: numpy.ndarray The video frames. """ - self.check_frame_inputs(frame_idxs[-1]) if isinstance(frame_idxs, int): frame_idxs = [frame_idxs] + self.check_frame_inputs(frame_idxs[-1]) if not all(np.diff(frame_idxs) == 1): return np.concatenate([self._get_single_frame(frame=idx) for idx in frame_idxs]) @@ -370,6 +419,9 @@ def get_channel_names(self) -> list: def get_num_planes(self) -> int: return self._num_planes + def get_dtype(self) -> DtypeType: + return self.get_frames(0).dtype + def check_frame_inputs(self, frame) -> None: if frame >= self._num_frames: raise ValueError(f"Frame index ({frame}) exceeds number of frames ({self._num_frames}).") From b1cb564c604a6195ae23b3fd829654c5bccb26c8 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Mon, 18 Sep 2023 17:28:20 -0700 Subject: [PATCH 14/47] added extra info to docstrings --- .../scanimagetiffimagingextractor.py | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index 2a7f6a1b..c86ad286 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -27,6 +27,18 @@ def _get_scanimage_reader() -> type: def extract_extra_metadata( file_path, ) -> dict: # TODO: Refactor neuroconv to reference this implementation to avoid duplication + """Extract metadata from a ScanImage TIFF file. + + Parameters + ---------- + file_path : PathType + Path to the TIFF file. + + Returns + ------- + extra_metadata: dict + Dictionary of metadata extracted from the TIFF file. + """ ScanImageTiffReader = _get_scanimage_reader() io = ScanImageTiffReader(str(file_path)) extra_metadata = {} @@ -41,7 +53,24 @@ def extract_extra_metadata( def parse_metadata(metadata): - """Parse metadata dictionary to extract relevant information. + """Parse metadata dictionary to extract relevant information and store it standard keys for ImagingExtractors. + + Currently supports + - sampling_frequency + - num_channels + - num_planes + - frames_per_slice + - channel_names + + Parameters + ---------- + metadata : dict + Dictionary of metadata extracted from the TIFF file. + + Returns + ------- + metadata_parsed: dict + Dictionary of parsed metadata. Notes ----- From 380dd63ad01d8df91d0c327ada1c5c1841002f39 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Mon, 18 Sep 2023 18:32:05 -0700 Subject: [PATCH 15/47] fixed bugs with multi-imaging extractor --- .../scanimagetiffimagingextractor.py | 10 ++++++---- tests/temp_test_scanimage.py | 13 ++++++++++++- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index c86ad286..224e1c6f 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -136,13 +136,15 @@ def _check_consistency_between_imaging_extractors(self, imaging_extractors: List - number of channels - channel names - data type + - num_frames """ properties_to_check = dict( get_sampling_frequency="The sampling frequency", get_image_size="The size of a frame", get_num_channels="The number of channels", get_channel_names="The name of the channels", - get_dtype="The data type.", + get_dtype="The data type", + get_num_frames="The number of frames", ) for method, property_message in properties_to_check.items(): values = [getattr(extractor, method)() for extractor in imaging_extractors] @@ -211,7 +213,7 @@ def get_num_planes(self) -> int: return self._num_planes def get_num_frames(self) -> int: - return self._num_frames + return self._imaging_extractors[0].get_num_frames() def get_sampling_frequency(self) -> float: return self._imaging_extractors[0].get_sampling_frequency() @@ -426,8 +428,8 @@ def get_video(self, start_frame=None, end_frame=None) -> np.ndarray: raw_end = np.min([raw_end, self._total_num_frames]) with ScanImageTiffReader(filename=str(self.file_path)) as io: raw_video = io.data(beg=raw_start, end=raw_end) - video = raw_video[self.channel :: self._num_channels] - video = video[self.plane :: self._num_planes] + video = raw_video[:: self._num_channels] + video = video[:: self._num_planes] return video def get_image_size(self) -> Tuple[int, int]: diff --git a/tests/temp_test_scanimage.py b/tests/temp_test_scanimage.py index 1bc9580c..029d1a8b 100644 --- a/tests/temp_test_scanimage.py +++ b/tests/temp_test_scanimage.py @@ -1,5 +1,5 @@ # scratch file to test scanimage tiff extractor -from roiextractors import ScanImageTiffImagingExtractor +from roiextractors import ScanImageTiffImagingExtractor, MultiImagingExtractor from roiextractors.extractors.tiffimagingextractors.scanimagetiffimagingextractor import ( extract_extra_metadata, parse_metadata, @@ -13,6 +13,9 @@ def main(): example_single = "/Users/pauladkisson/Documents/CatalystNeuro/ROIExtractors/ophys_testing_data/imaging_datasets/ScanImage/scanimage_20220801_single.tif" example_volume = "/Users/pauladkisson/Documents/CatalystNeuro/ROIExtractors/ophys_testing_data/imaging_datasets/ScanImage/scanimage_20220801_volume.tif" example_multivolume = "/Users/pauladkisson/Documents/CatalystNeuro/ROIExtractors/ophys_testing_data/imaging_datasets/ScanImage/scanimage_20220801_multivolume.tif" + multi_example_holo = [ + f"/Volumes/T7/CatalystNeuro/NWB/MouseV1/raw-tiffs/2ret/20230119_w57_1_2ret_{i:05d}.tif" for i in range(1, 11) + ] extractor = ScanImageTiffImagingExtractor(file_path=example_test, sampling_frequency=30.0) print("Example test file loads!") @@ -21,6 +24,14 @@ def main(): metadata_parsed = parse_metadata(metadata) extractor = ScanImageTiffMultiPlaneImagingExtractor(file_path=example_holo, **metadata_parsed) print("Example holographic file loads!") + video = extractor.get_video() + print("Video shape:", video.shape) + + extractors = [ScanImageTiffMultiPlaneImagingExtractor(file_path=f, **metadata_parsed) for f in multi_example_holo] + extractor = MultiImagingExtractor(imaging_extractors=extractors) + print("Example multi-holographic file loads!") + video = extractor.get_video() + print("Video shape:", video.shape) metadata = extract_extra_metadata(example_single) metadata_parsed = parse_metadata(metadata) From d921342049f3cfa137332f9b0706105b867a7398 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Mon, 18 Sep 2023 18:38:09 -0700 Subject: [PATCH 16/47] Added depth to get_image_size to allow compatibility with MultiImagingExtractor --- .../scanimagetiffimagingextractor.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index 224e1c6f..f205dc4a 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -171,7 +171,7 @@ def get_video(self, start_frame: Optional[int] = None, end_frame: Optional[int] start_frame = start_frame if start_frame is not None else 0 end_frame = end_frame if end_frame is not None else self.get_num_frames() - video = np.zeros((end_frame - start_frame, *self.get_image_size(), self.get_num_planes()), self.get_dtype()) + video = np.zeros((end_frame - start_frame, *self.get_image_size()), self.get_dtype()) for i, imaging_extractor in enumerate(self._imaging_extractors): video[..., i] = imaging_extractor.get_video(start_frame, end_frame) return video @@ -193,14 +193,22 @@ def get_frames(self, frame_idxs: ArrayType) -> np.ndarray: frame_idxs = [frame_idxs] if not all(np.diff(frame_idxs) == 1): - frames = np.zeros((len(frame_idxs), *self.get_image_size(), self.get_num_planes()), self.get_dtype()) + frames = np.zeros((len(frame_idxs), *self.get_image_size()), self.get_dtype()) for i, imaging_extractor in enumerate(self._imaging_extractors): frames[..., i] = imaging_extractor.get_frames(frame_idxs) else: return self.get_video(start_frame=frame_idxs[0], end_frame=frame_idxs[-1] + 1) def get_image_size(self) -> Tuple: - return self._imaging_extractors[0].get_image_size() + """Get the size of a single frame. + + Returns + ------- + image_size: tuple + The size of a single frame (num_rows, num_columns, num_planes). + """ + image_size = (*self._imaging_extractors[0].get_image_size(), self.get_num_planes()) + return image_size def get_num_planes(self) -> int: """Get the number of depth planes. From 3b65bf8bbfd741725110233fa5ae08f83be1c8f2 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Mon, 18 Sep 2023 19:07:30 -0700 Subject: [PATCH 17/47] Refactored new ScanImage --> ScanImageTiffSinglePlaneIE and keep legacy scanimage to keep backwards compatibility --- .../scanimagetiffimagingextractor.py | 164 ++++++++++++++---- tests/temp_test_scanimage.py | 24 +-- 2 files changed, 129 insertions(+), 59 deletions(-) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index f205dc4a..156ffea6 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -246,15 +246,16 @@ class ScanImageTiffMultiPlaneImagingExtractor(MultiPlaneImagingExtractor): def __init__( self, file_path: PathType, - sampling_frequency: float, channel: Optional[int] = 0, - num_channels: Optional[int] = 1, - num_planes: Optional[int] = 1, - frames_per_slice: Optional[int] = 1, - channel_names: Optional[list] = None, ) -> None: self.file_path = Path(file_path) self.metadata = extract_extra_metadata(file_path) + parsed_metadata = parse_metadata(self.metadata) + sampling_frequency = parsed_metadata["sampling_frequency"] + num_channels = parsed_metadata["num_channels"] + num_planes = parsed_metadata["num_planes"] + frames_per_slice = parsed_metadata["frames_per_slice"] + channel_names = parsed_metadata["channel_names"] self.channel = channel if channel >= num_channels: raise ValueError(f"Channel index ({channel}) exceeds number of channels ({num_channels}).") @@ -265,14 +266,8 @@ def __init__( ) imaging_extractors = [] for plane in range(num_planes): - imaging_extractor = ScanImageTiffImagingExtractor( - file_path=file_path, - sampling_frequency=sampling_frequency, - channel=channel, - num_channels=num_channels, - plane=plane, - num_planes=num_planes, - channel_names=channel_names, + imaging_extractor = ScanImageTiffSinglePlaneImagingExtractor( + file_path=file_path, channel=channel, plane=plane ) imaging_extractors.append(imaging_extractor) super().__init__(imaging_extractors=imaging_extractors) @@ -281,7 +276,7 @@ def __init__( ), "All imaging extractors must have the same number of planes." -class ScanImageTiffImagingExtractor(ImagingExtractor): +class ScanImageTiffSinglePlaneImagingExtractor(ImagingExtractor): """Specialized extractor for reading TIFF files produced via ScanImage.""" extractor_name = "ScanImageTiffImaging" @@ -291,13 +286,8 @@ class ScanImageTiffImagingExtractor(ImagingExtractor): def __init__( self, file_path: PathType, - sampling_frequency: float, channel: Optional[int] = 0, - num_channels: Optional[int] = 1, plane: Optional[int] = 0, - num_planes: Optional[int] = 1, - frames_per_slice: Optional[int] = 1, - channel_names: Optional[list] = None, ) -> None: """Create a ScanImageTiffImagingExtractor instance from a TIFF file produced by ScanImage. @@ -314,35 +304,27 @@ def __init__( ---------- file_path : PathType Path to the TIFF file. - sampling_frequency : float - Sampling frequency of each plane (scanVolumeRate) in Hz. channel : int, optional Index of the optical channel for this extractor (default=0). - num_channels : int, optional - Number of active channels that were acquired (default=1). plane : int, optional Index of the depth plane for this extractor (default=0). - num_planes : int, optional - Number of depth planes that were acquired (default=1). - frames_per_slice : int, optional - Number of frames per depth plane that were acquired (default=1). - channel_names : list, optional - Names of the channels (default=None). """ super().__init__() self.file_path = Path(file_path) - self._sampling_frequency = sampling_frequency - self.metadata = extract_extra_metadata(file_path) self.channel = channel - self._num_channels = num_channels self.plane = plane - self._num_planes = num_planes - self._channel_names = channel_names - if channel >= num_channels: + self.metadata = extract_extra_metadata(file_path) + parsed_metadata = parse_metadata(self.metadata) + self._sampling_frequency = parsed_metadata["sampling_frequency"] + self._num_channels = parsed_metadata["num_channels"] + self._num_planes = parsed_metadata["num_planes"] + self._frames_per_slice = parsed_metadata["frames_per_slice"] + self._channel_names = parsed_metadata["channel_names"] + if channel >= self._num_channels: raise ValueError(f"Channel index ({channel}) exceeds number of channels ({num_channels}).") - if plane >= num_planes: + if plane >= self._num_planes: raise ValueError(f"Plane index ({plane}) exceeds number of planes ({num_planes}).") - if frames_per_slice != 1: + if self._frames_per_slice != 1: raise NotImplementedError( "Extractor cannot handle multiple frames per slice. Please raise an issue to request this feature: " "https://github.com/catalystneuro/roiextractors/issues " @@ -490,3 +472,111 @@ def frame_to_raw_index(self, frame): """ raw_index = (frame * self._num_planes * self._num_channels) + (self.plane * self._num_channels) + self.channel return raw_index + + +class ScanImageTiffImagingExtractor(ImagingExtractor): + """Specialized extractor for reading TIFF files produced via ScanImage.""" + + extractor_name = "ScanImageTiffImaging" + is_writable = True + mode = "file" + + def __init__( + self, + file_path: PathType, + sampling_frequency: FloatType, + ): + """Create a ScanImageTiffImagingExtractor instance from a TIFF file produced by ScanImage. + + This extractor allows for lazy accessing of slices, unlike + :py:class:`~roiextractors.extractors.tiffimagingextractors.TiffImagingExtractor`. + However, direct slicing of the underlying data structure is not equivalent to a numpy memory map. + + Parameters + ---------- + file_path : PathType + Path to the TIFF file. + sampling_frequency : float + The frequency at which the frames were sampled, in Hz. + """ + ScanImageTiffReader = _get_scanimage_reader() + + super().__init__() + self.file_path = Path(file_path) + self._sampling_frequency = sampling_frequency + valid_suffixes = [".tiff", ".tif", ".TIFF", ".TIF"] + if self.file_path.suffix not in valid_suffixes: + suffix_string = ", ".join(valid_suffixes[:-1]) + f", or {valid_suffixes[-1]}" + warn( + f"Suffix ({self.file_path.suffix}) is not of type {suffix_string}! " + f"The {self.extractor_name}Extractor may not be appropriate for the file." + ) + + with ScanImageTiffReader(str(self.file_path)) as io: + shape = io.shape() # [frames, rows, columns] + if len(shape) == 3: + self._num_frames, self._num_rows, self._num_columns = shape + self._num_channels = 1 + else: # no example file for multiple color channels or depths + raise NotImplementedError( + "Extractor cannot handle 4D TIFF data. Please raise an issue to request this feature: " + "https://github.com/catalystneuro/roiextractors/issues " + ) + + def get_frames(self, frame_idxs: ArrayType, channel: int = 0) -> np.ndarray: + ScanImageTiffReader = _get_scanimage_reader() + + squeeze_data = False + if isinstance(frame_idxs, int): + squeeze_data = True + frame_idxs = [frame_idxs] + + if not all(np.diff(frame_idxs) == 1): + return np.concatenate([self._get_single_frame(idx=idx) for idx in frame_idxs]) + else: + with ScanImageTiffReader(filename=str(self.file_path)) as io: + frames = io.data(beg=frame_idxs[0], end=frame_idxs[-1] + 1) + if squeeze_data: + frames = frames.squeeze() + return frames + + # Data accessed through an open ScanImageTiffReader io gets scrambled if there are multiple calls. + # Thus, open fresh io in context each time something is needed. + def _get_single_frame(self, idx: int) -> np.ndarray: + """Get a single frame of data from the TIFF file. + + Parameters + ---------- + idx : int + The index of the frame to retrieve. + + Returns + ------- + frame: numpy.ndarray + The frame of data. + """ + ScanImageTiffReader = _get_scanimage_reader() + + with ScanImageTiffReader(str(self.file_path)) as io: + return io.data(beg=idx, end=idx + 1) + + def get_video(self, start_frame=None, end_frame=None, channel: Optional[int] = 0) -> np.ndarray: + ScanImageTiffReader = _get_scanimage_reader() + + with ScanImageTiffReader(filename=str(self.file_path)) as io: + return io.data(beg=start_frame, end=end_frame) + + def get_image_size(self) -> Tuple[int, int]: + return (self._num_rows, self._num_columns) + + def get_num_frames(self) -> int: + return self._num_frames + + def get_sampling_frequency(self) -> float: + return self._sampling_frequency + + def get_num_channels(self) -> int: + return self._num_channels + + def get_channel_names(self) -> list: + pass diff --git a/tests/temp_test_scanimage.py b/tests/temp_test_scanimage.py index 029d1a8b..7db1dd15 100644 --- a/tests/temp_test_scanimage.py +++ b/tests/temp_test_scanimage.py @@ -20,37 +20,17 @@ def main(): extractor = ScanImageTiffImagingExtractor(file_path=example_test, sampling_frequency=30.0) print("Example test file loads!") - metadata = extract_extra_metadata(example_holo) - metadata_parsed = parse_metadata(metadata) - extractor = ScanImageTiffMultiPlaneImagingExtractor(file_path=example_holo, **metadata_parsed) + extractor = ScanImageTiffMultiPlaneImagingExtractor(file_path=example_holo) print("Example holographic file loads!") video = extractor.get_video() print("Video shape:", video.shape) - extractors = [ScanImageTiffMultiPlaneImagingExtractor(file_path=f, **metadata_parsed) for f in multi_example_holo] + extractors = [ScanImageTiffMultiPlaneImagingExtractor(file_path=f) for f in multi_example_holo] extractor = MultiImagingExtractor(imaging_extractors=extractors) print("Example multi-holographic file loads!") video = extractor.get_video() print("Video shape:", video.shape) - metadata = extract_extra_metadata(example_single) - metadata_parsed = parse_metadata(metadata) - metadata_parsed["frames_per_slice"] = 1 # Multiple frames per slice is not supported yet - extractor = ScanImageTiffImagingExtractor(file_path=example_single, **metadata_parsed) - print("Example single-plane file loads!") - - metadata = extract_extra_metadata(example_volume) - metadata_parsed = parse_metadata(metadata) - metadata_parsed["frames_per_slice"] = 1 # Multiple frames per slice is not supported yet - extractor = ScanImageTiffImagingExtractor(file_path=example_volume, **metadata_parsed) - print("Example volume file loads!") - - metadata = extract_extra_metadata(example_multivolume) - metadata_parsed = parse_metadata(metadata) - metadata_parsed["frames_per_slice"] = 1 # Multiple frames per slice is not supported yet - extractor = ScanImageTiffImagingExtractor(file_path=example_multivolume, **metadata_parsed) - print("Example multivolume file loads!") - if __name__ == "__main__": main() From 84c194640056635ed94974dd5d6d26340ab4a58d Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Mon, 18 Sep 2023 19:10:04 -0700 Subject: [PATCH 18/47] Added legacy message to old ScanImageTiff --- .../tiffimagingextractors/scanimagetiffimagingextractor.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index 156ffea6..d0796f58 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -475,7 +475,11 @@ def frame_to_raw_index(self, frame): class ScanImageTiffImagingExtractor(ImagingExtractor): - """Specialized extractor for reading TIFF files produced via ScanImage.""" + """Specialized extractor for reading TIFF files produced via ScanImage. + + This implementation is for legacy purposes and is not recommended for use. + Please use ScanImageTiffSinglePlaneImagingExtractor or ScanImageTiffMultiPlaneImagingExtractor instead. + """ extractor_name = "ScanImageTiffImaging" is_writable = True From c3fcfea2fe89ba4ba5f0e3747f44621f873212b1 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Tue, 19 Sep 2023 11:08:43 -0700 Subject: [PATCH 19/47] Added legacy metadata parsing function --- .../scanimagetiffimagingextractor.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index d0796f58..91ad03d7 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -93,6 +93,35 @@ def parse_metadata(metadata): return metadata_parsed +def parse_metadata_v3_8(metadata): + """Parse metadata dictionary to extract relevant information and store it standard keys for ImagingExtractors. + + Requires old version of metadata (v3.8). + Currently supports + - sampling frequency + - num_channels + - num_planes + + Parameters + ---------- + metadata : dict + Dictionary of metadata extracted from the TIFF file. + + Returns + ------- + metadata_parsed: dict + Dictionary of parsed metadata. + """ + sampling_frequency = float(metadata["state.acq.frameRate"]) + num_channels = int(metadata["state.acq.numberOfChannelsSave"]) + num_planes = int(metadata["state.acq.numberOfZSlices"]) + metadata_parsed = dict( + sampling_frequency=sampling_frequency, + num_channels=num_channels, + num_planes=num_planes, + ) + + class MultiPlaneImagingExtractor(ImagingExtractor): """Class to combine multiple ImagingExtractor objects by depth plane.""" From 80ec40c661e9b8e9c063e9abb632094a63f453b6 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Tue, 19 Sep 2023 11:29:59 -0700 Subject: [PATCH 20/47] Added deprecation warning to legacy scanimage extractor --- .../scanimagetiffimagingextractor.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index 91ad03d7..a7827a48 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -305,7 +305,7 @@ def __init__( ), "All imaging extractors must have the same number of planes." -class ScanImageTiffSinglePlaneImagingExtractor(ImagingExtractor): +class ScanImageTiffSinglePlaneImagingExtractor(ImagingExtractor): # TODO: Remove this extractor on/after December 2023 """Specialized extractor for reading TIFF files produced via ScanImage.""" extractor_name = "ScanImageTiffImaging" @@ -338,6 +338,12 @@ def __init__( plane : int, optional Index of the depth plane for this extractor (default=0). """ + deprecation_message = """ + This extractor is being deprecated on or after December 2023 in favor of + ScanImageTiffMultiPlaneImagingExtractor or ScanImageTiffSinglePlaneImagingExtractor. Please use one of these + extractors instead. + """ + warn(deprecation_message, category=FutureWarning) super().__init__() self.file_path = Path(file_path) self.channel = channel From bc4fafe49f8ab18dcb21a9d6f1d8a26e2ecd50a3 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Tue, 19 Sep 2023 11:31:47 -0700 Subject: [PATCH 21/47] Added deprecation warning to legacy scanimage extractor --- .../scanimagetiffimagingextractor.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index a7827a48..6ceba254 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -305,7 +305,7 @@ def __init__( ), "All imaging extractors must have the same number of planes." -class ScanImageTiffSinglePlaneImagingExtractor(ImagingExtractor): # TODO: Remove this extractor on/after December 2023 +class ScanImageTiffSinglePlaneImagingExtractor(ImagingExtractor): """Specialized extractor for reading TIFF files produced via ScanImage.""" extractor_name = "ScanImageTiffImaging" @@ -338,12 +338,6 @@ def __init__( plane : int, optional Index of the depth plane for this extractor (default=0). """ - deprecation_message = """ - This extractor is being deprecated on or after December 2023 in favor of - ScanImageTiffMultiPlaneImagingExtractor or ScanImageTiffSinglePlaneImagingExtractor. Please use one of these - extractors instead. - """ - warn(deprecation_message, category=FutureWarning) super().__init__() self.file_path = Path(file_path) self.channel = channel @@ -356,9 +350,9 @@ def __init__( self._frames_per_slice = parsed_metadata["frames_per_slice"] self._channel_names = parsed_metadata["channel_names"] if channel >= self._num_channels: - raise ValueError(f"Channel index ({channel}) exceeds number of channels ({num_channels}).") + raise ValueError(f"Channel index ({channel}) exceeds number of channels ({self._num_channels}).") if plane >= self._num_planes: - raise ValueError(f"Plane index ({plane}) exceeds number of planes ({num_planes}).") + raise ValueError(f"Plane index ({plane}) exceeds number of planes ({self._num_planes}).") if self._frames_per_slice != 1: raise NotImplementedError( "Extractor cannot handle multiple frames per slice. Please raise an issue to request this feature: " @@ -509,7 +503,7 @@ def frame_to_raw_index(self, frame): return raw_index -class ScanImageTiffImagingExtractor(ImagingExtractor): +class ScanImageTiffImagingExtractor(ImagingExtractor): # TODO: Remove this extractor on/after December 2023 """Specialized extractor for reading TIFF files produced via ScanImage. This implementation is for legacy purposes and is not recommended for use. @@ -538,6 +532,12 @@ def __init__( sampling_frequency : float The frequency at which the frames were sampled, in Hz. """ + deprecation_message = """ + This extractor is being deprecated on or after December 2023 in favor of + ScanImageTiffMultiPlaneImagingExtractor or ScanImageTiffSinglePlaneImagingExtractor. Please use one of these + extractors instead. + """ + warn(deprecation_message, category=FutureWarning) ScanImageTiffReader = _get_scanimage_reader() super().__init__() From 1965be8e2bfb15b5e55247347b82f30c4c6284c0 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Tue, 19 Sep 2023 12:47:27 -0700 Subject: [PATCH 22/47] removed redundant metadata/input validation in ScanImageTiffMultiPlaneImagingExtractor --- .../scanimagetiffimagingextractor.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index 6ceba254..8fb1d502 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -278,21 +278,10 @@ def __init__( channel: Optional[int] = 0, ) -> None: self.file_path = Path(file_path) + self.channel = channel self.metadata = extract_extra_metadata(file_path) parsed_metadata = parse_metadata(self.metadata) - sampling_frequency = parsed_metadata["sampling_frequency"] - num_channels = parsed_metadata["num_channels"] num_planes = parsed_metadata["num_planes"] - frames_per_slice = parsed_metadata["frames_per_slice"] - channel_names = parsed_metadata["channel_names"] - self.channel = channel - if channel >= num_channels: - raise ValueError(f"Channel index ({channel}) exceeds number of channels ({num_channels}).") - if frames_per_slice != 1: - raise NotImplementedError( - "Extractor cannot handle multiple frames per slice. Please raise an issue to request this feature: " - "https://github.com/catalystneuro/roiextractors/issues " - ) imaging_extractors = [] for plane in range(num_planes): imaging_extractor = ScanImageTiffSinglePlaneImagingExtractor( From c160d05d166ad57a7622fa2534c7ec87c312e628 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Tue, 19 Sep 2023 12:58:19 -0700 Subject: [PATCH 23/47] changed multiple frames per slice to warning to allow use of existing testing data --- .../scanimagetiffimagingextractor.py | 5 +++-- tests/temp_test_scanimage.py | 10 ++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index 8fb1d502..2204ed4c 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -343,8 +343,9 @@ def __init__( if plane >= self._num_planes: raise ValueError(f"Plane index ({plane}) exceeds number of planes ({self._num_planes}).") if self._frames_per_slice != 1: - raise NotImplementedError( - "Extractor cannot handle multiple frames per slice. Please raise an issue to request this feature: " + warn( + "Multiple frames per slice have not been tested and may produce incorrect output. " + "Please raise an issue to request this feature: " "https://github.com/catalystneuro/roiextractors/issues " ) diff --git a/tests/temp_test_scanimage.py b/tests/temp_test_scanimage.py index 7db1dd15..22ff3ffa 100644 --- a/tests/temp_test_scanimage.py +++ b/tests/temp_test_scanimage.py @@ -31,6 +31,16 @@ def main(): video = extractor.get_video() print("Video shape:", video.shape) + extractor = ScanImageTiffMultiPlaneImagingExtractor(file_path=example_volume) + print("Example volume file loads!") + video = extractor.get_video() + print("Video shape:", video.shape) + + extractor = ScanImageTiffMultiPlaneImagingExtractor(file_path=example_multivolume) + print("Example multi-volume file loads!") + video = extractor.get_video() + print("Video shape:", video.shape) + if __name__ == "__main__": main() From d6fe0a51fc69cbe5480caa7de6370808f940a0de Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Tue, 19 Sep 2023 13:32:28 -0700 Subject: [PATCH 24/47] inital test setup --- src/roiextractors/extractorlist.py | 4 ++++ .../tiffimagingextractors/__init__.py | 12 +++++++++-- tests/test_scanimagetiffimagingextractor.py | 20 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 tests/test_scanimagetiffimagingextractor.py diff --git a/src/roiextractors/extractorlist.py b/src/roiextractors/extractorlist.py index 091265c2..dff3136a 100644 --- a/src/roiextractors/extractorlist.py +++ b/src/roiextractors/extractorlist.py @@ -15,6 +15,8 @@ from .extractors.tiffimagingextractors import ( TiffImagingExtractor, ScanImageTiffImagingExtractor, + ScanImageTiffSinglePlaneImagingExtractor, + ScanImageTiffMultiPlaneImagingExtractor, BrukerTiffMultiPlaneImagingExtractor, BrukerTiffSinglePlaneImagingExtractor, MicroManagerTiffImagingExtractor, @@ -31,6 +33,8 @@ Hdf5ImagingExtractor, TiffImagingExtractor, ScanImageTiffImagingExtractor, + ScanImageTiffSinglePlaneImagingExtractor, + ScanImageTiffMultiPlaneImagingExtractor, BrukerTiffMultiPlaneImagingExtractor, BrukerTiffSinglePlaneImagingExtractor, MicroManagerTiffImagingExtractor, diff --git a/src/roiextractors/extractors/tiffimagingextractors/__init__.py b/src/roiextractors/extractors/tiffimagingextractors/__init__.py index b8f5cf54..3f3a3618 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/__init__.py +++ b/src/roiextractors/extractors/tiffimagingextractors/__init__.py @@ -16,7 +16,11 @@ TiffImagingExtractor A ImagingExtractor for TIFF files. ScanImageTiffImagingExtractor - Specialized extractor for reading TIFF files produced via ScanImage. + Legacy extractor for reading TIFF files produced via ScanImage v3.8. +ScanImageTiffSinglePlaneImagingExtractor + Specialized extractor for reading single-plane TIFF files produced via ScanImage. +ScanImageTiffMultiPlaneImagingExtractor + Specialized extractor for reading multi-plane TIFF files produced via ScanImage. BrukerTiffMultiPlaneImagingExtractor Specialized extractor for reading TIFF files produced via Bruker. BrukerTiffSinglePlaneImagingExtractor @@ -25,6 +29,10 @@ Specialized extractor for reading TIFF files produced via Micro-Manager. """ from .tiffimagingextractor import TiffImagingExtractor -from .scanimagetiffimagingextractor import ScanImageTiffImagingExtractor +from .scanimagetiffimagingextractor import ( + ScanImageTiffImagingExtractor, + ScanImageTiffMultiPlaneImagingExtractor, + ScanImageTiffSinglePlaneImagingExtractor, +) from .brukertiffimagingextractor import BrukerTiffMultiPlaneImagingExtractor, BrukerTiffSinglePlaneImagingExtractor from .micromanagertiffimagingextractor import MicroManagerTiffImagingExtractor diff --git a/tests/test_scanimagetiffimagingextractor.py b/tests/test_scanimagetiffimagingextractor.py new file mode 100644 index 00000000..31e82c3d --- /dev/null +++ b/tests/test_scanimagetiffimagingextractor.py @@ -0,0 +1,20 @@ +import pytest +from pathlib import Path +from tempfile import mkdtemp +from shutil import rmtree, copy + +from ScanImageTiffReader import ScanImageTiffReader +from roiextractors import ScanImageTiffSinglePlaneImagingExtractor, ScanImageTiffMultiPlaneImagingExtractor + +from .setup_paths import OPHYS_DATA_PATH + + +@pytest.fixture(scope="module") +def scan_image_tiff_single_plane_imaging_extractor(): + file_path = OPHYS_DATA_PATH / "imaging_datasets" / "ScanImage" / "scanimage_20220801_volume.tif" + return ScanImageTiffSinglePlaneImagingExtractor(file_path=file_path) + + +def test_get_video(scan_image_tiff_single_plane_imaging_extractor): + video = scan_image_tiff_single_plane_imaging_extractor.get_video() + assert video.shape == (1, 512, 512) From 76f8485dceaadccb171f970903bfdc78c60a0e94 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Tue, 19 Sep 2023 18:54:46 -0700 Subject: [PATCH 25/47] added tests for new ScanImage stuff --- .../scanimagetiffimagingextractor.py | 1 + tests/test_scanimagetiffimagingextractor.py | 247 +++++++++++++++++- 2 files changed, 244 insertions(+), 4 deletions(-) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index 2204ed4c..599bdbbf 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -120,6 +120,7 @@ def parse_metadata_v3_8(metadata): num_channels=num_channels, num_planes=num_planes, ) + return metadata_parsed class MultiPlaneImagingExtractor(ImagingExtractor): diff --git a/tests/test_scanimagetiffimagingextractor.py b/tests/test_scanimagetiffimagingextractor.py index 31e82c3d..0025a1c9 100644 --- a/tests/test_scanimagetiffimagingextractor.py +++ b/tests/test_scanimagetiffimagingextractor.py @@ -2,19 +2,258 @@ from pathlib import Path from tempfile import mkdtemp from shutil import rmtree, copy +from numpy.testing import assert_array_equal from ScanImageTiffReader import ScanImageTiffReader from roiextractors import ScanImageTiffSinglePlaneImagingExtractor, ScanImageTiffMultiPlaneImagingExtractor +from roiextractors.extractors.tiffimagingextractors.scanimagetiffimagingextractor import ( + extract_extra_metadata, + parse_metadata, + parse_metadata_v3_8, +) from .setup_paths import OPHYS_DATA_PATH +scan_image_path = OPHYS_DATA_PATH / "imaging_datasets" / "ScanImage" +test_files = [ + "scanimage_20220801_volume.tif", + "scanimage_20220801_multivolume.tif", + "scanimage_20230119_adesnik_00001.tif", +] +file_paths = [scan_image_path / test_file for test_file in test_files] + + +def metadata_string_to_dict(metadata_string): + metadata_dict = { + x.split("=")[0].strip(): x.split("=")[1].strip() + for x in metadata_string.replace("\n", "\r").split("\r") + if "=" in x + } + return metadata_dict + + +@pytest.fixture(scope="module", params=file_paths) +def scan_image_tiff_single_plane_imaging_extractor(request): + return ScanImageTiffSinglePlaneImagingExtractor(file_path=request.param) + + +@pytest.fixture( + scope="module", + params=[ + dict(channel=0, plane=0), + dict(channel=0, plane=1), + dict(channel=0, plane=2), + dict(channel=1, plane=0), + dict(channel=1, plane=1), + dict(channel=1, plane=2), + ], +) # Only the adesnik file has many (>2) frames per plane and multiple (2) channels. +def scan_image_tiff_single_plane_imaging_extractor_adesnik(request): + file_path = scan_image_path / "scanimage_20230119_adesnik_00001.tif" + return ScanImageTiffSinglePlaneImagingExtractor(file_path=file_path, **request.param) + + +@pytest.fixture(scope="module") +def num_planes_adesnik(): + return 3 + @pytest.fixture(scope="module") -def scan_image_tiff_single_plane_imaging_extractor(): - file_path = OPHYS_DATA_PATH / "imaging_datasets" / "ScanImage" / "scanimage_20220801_volume.tif" - return ScanImageTiffSinglePlaneImagingExtractor(file_path=file_path) +def num_channels_adesnik(): + return 2 + + +@pytest.mark.parametrize("frame_idxs", (0, [0])) +def test_get_frames(scan_image_tiff_single_plane_imaging_extractor, frame_idxs): + frames = scan_image_tiff_single_plane_imaging_extractor.get_frames(frame_idxs=frame_idxs) + file_path = str(scan_image_tiff_single_plane_imaging_extractor.file_path) + with ScanImageTiffReader(file_path) as io: + if isinstance(frame_idxs, int): + frame_idxs = [frame_idxs] + assert_array_equal(frames, io.data()[frame_idxs]) + + +@pytest.mark.parametrize("frame_idxs", ([0, 1, 2], [1, 3, 31])) # 31 is the last frame in the adesnik file +def test_get_frames_adesnik( + scan_image_tiff_single_plane_imaging_extractor_adesnik, num_planes_adesnik, num_channels_adesnik, frame_idxs +): + frames = scan_image_tiff_single_plane_imaging_extractor_adesnik.get_frames(frame_idxs=frame_idxs) + file_path = str(scan_image_tiff_single_plane_imaging_extractor_adesnik.file_path) + plane = scan_image_tiff_single_plane_imaging_extractor_adesnik.plane + channel = scan_image_tiff_single_plane_imaging_extractor_adesnik.channel + raw_idxs = [ + idx * num_planes_adesnik * num_channels_adesnik + plane * num_channels_adesnik + channel for idx in frame_idxs + ] + with ScanImageTiffReader(file_path) as io: + assert_array_equal(frames, io.data()[raw_idxs]) + + +def test__get_single_frame(scan_image_tiff_single_plane_imaging_extractor): + frame = scan_image_tiff_single_plane_imaging_extractor._get_single_frame(frame=0) + file_path = str(scan_image_tiff_single_plane_imaging_extractor.file_path) + with ScanImageTiffReader(file_path) as io: + assert_array_equal(frame, io.data()[:1]) def test_get_video(scan_image_tiff_single_plane_imaging_extractor): video = scan_image_tiff_single_plane_imaging_extractor.get_video() - assert video.shape == (1, 512, 512) + file_path = str(scan_image_tiff_single_plane_imaging_extractor.file_path) + num_channels = scan_image_tiff_single_plane_imaging_extractor.get_num_channels() + num_planes = scan_image_tiff_single_plane_imaging_extractor.get_num_planes() + with ScanImageTiffReader(file_path) as io: + assert_array_equal(video, io.data()[:: num_planes * num_channels]) + + +@pytest.mark.parametrize("start_frame, end_frame", [(0, 2), (5, 10), (20, 32)]) +def test_get_video_adesnik( + scan_image_tiff_single_plane_imaging_extractor_adesnik, + num_planes_adesnik, + num_channels_adesnik, + start_frame, + end_frame, +): + video = scan_image_tiff_single_plane_imaging_extractor_adesnik.get_video( + start_frame=start_frame, end_frame=end_frame + ) + file_path = str(scan_image_tiff_single_plane_imaging_extractor_adesnik.file_path) + plane = scan_image_tiff_single_plane_imaging_extractor_adesnik.plane + channel = scan_image_tiff_single_plane_imaging_extractor_adesnik.channel + raw_idxs = [ + idx * num_planes_adesnik * num_channels_adesnik + plane * num_channels_adesnik + channel + for idx in range(start_frame, end_frame) + ] + with ScanImageTiffReader(file_path) as io: + assert_array_equal(video, io.data()[raw_idxs]) + + +def test_get_image_size(scan_image_tiff_single_plane_imaging_extractor): + image_size = scan_image_tiff_single_plane_imaging_extractor.get_image_size() + file_path = str(scan_image_tiff_single_plane_imaging_extractor.file_path) + with ScanImageTiffReader(file_path) as io: + assert image_size == tuple(io.shape()[1:]) + + +def test_get_num_frames(scan_image_tiff_single_plane_imaging_extractor): + num_frames = scan_image_tiff_single_plane_imaging_extractor.get_num_frames() + num_channels = scan_image_tiff_single_plane_imaging_extractor.get_num_channels() + num_planes = scan_image_tiff_single_plane_imaging_extractor.get_num_planes() + file_path = str(scan_image_tiff_single_plane_imaging_extractor.file_path) + with ScanImageTiffReader(file_path) as io: + assert num_frames == io.shape()[0] // (num_channels * num_planes) + + +def test_get_sampling_frequency(scan_image_tiff_single_plane_imaging_extractor): + sampling_frequency = scan_image_tiff_single_plane_imaging_extractor.get_sampling_frequency() + file_path = str(scan_image_tiff_single_plane_imaging_extractor.file_path) + with ScanImageTiffReader(file_path) as io: + metadata_string = io.metadata() + metadata_dict = metadata_string_to_dict(metadata_string) + assert sampling_frequency == float(metadata_dict["SI.hRoiManager.scanVolumeRate"]) + + +def test_get_num_channels(scan_image_tiff_single_plane_imaging_extractor): + num_channels = scan_image_tiff_single_plane_imaging_extractor.get_num_channels() + file_path = str(scan_image_tiff_single_plane_imaging_extractor.file_path) + with ScanImageTiffReader(file_path) as io: + metadata_string = io.metadata() + metadata_dict = metadata_string_to_dict(metadata_string) + assert num_channels == len(metadata_dict["SI.hChannels.channelsActive"].split(";")) + + +def test_get_channel_names(scan_image_tiff_single_plane_imaging_extractor): + channel_names = scan_image_tiff_single_plane_imaging_extractor.get_channel_names() + num_channels = scan_image_tiff_single_plane_imaging_extractor.get_num_channels() + file_path = str(scan_image_tiff_single_plane_imaging_extractor.file_path) + with ScanImageTiffReader(file_path) as io: + metadata_string = io.metadata() + metadata_dict = metadata_string_to_dict(metadata_string) + assert channel_names == metadata_dict["SI.hChannels.channelName"].split("'")[1::2][:num_channels] + + +def test_get_num_planes(scan_image_tiff_single_plane_imaging_extractor): + num_planes = scan_image_tiff_single_plane_imaging_extractor.get_num_planes() + file_path = str(scan_image_tiff_single_plane_imaging_extractor.file_path) + with ScanImageTiffReader(file_path) as io: + metadata_string = io.metadata() + metadata_dict = metadata_string_to_dict(metadata_string) + assert num_planes == int(metadata_dict["SI.hStackManager.numSlices"]) + + +def test_get_dtype(scan_image_tiff_single_plane_imaging_extractor): + dtype = scan_image_tiff_single_plane_imaging_extractor.get_dtype() + file_path = str(scan_image_tiff_single_plane_imaging_extractor.file_path) + with ScanImageTiffReader(file_path) as io: + assert dtype == io.data().dtype + + +def test_check_frame_inputs_valid(scan_image_tiff_single_plane_imaging_extractor): + scan_image_tiff_single_plane_imaging_extractor.check_frame_inputs(frame=0) + + +def test_check_frame_inputs_invalid(scan_image_tiff_single_plane_imaging_extractor): + num_frames = scan_image_tiff_single_plane_imaging_extractor.get_num_frames() + with pytest.raises(ValueError): + scan_image_tiff_single_plane_imaging_extractor.check_frame_inputs(frame=num_frames + 1) + + +@pytest.mark.parametrize("frame", (0, 10, 31)) +def test_frame_to_raw_index_adesnik( + scan_image_tiff_single_plane_imaging_extractor_adesnik, num_channels_adesnik, num_planes_adesnik, frame +): + raw_index = scan_image_tiff_single_plane_imaging_extractor_adesnik.frame_to_raw_index(frame=frame) + plane = scan_image_tiff_single_plane_imaging_extractor_adesnik.plane + channel = scan_image_tiff_single_plane_imaging_extractor_adesnik.channel + assert raw_index == (frame * num_planes_adesnik * num_channels_adesnik) + (plane * num_channels_adesnik) + channel + + +@pytest.mark.parametrize("file_path", file_paths) +def test_extract_extra_metadata(file_path): + metadata = extract_extra_metadata(file_path) + io = ScanImageTiffReader(str(file_path)) + extra_metadata = {} + for metadata_string in (io.description(iframe=0), io.metadata()): + metadata_dict = { + x.split("=")[0].strip(): x.split("=")[1].strip() + for x in metadata_string.replace("\n", "\r").split("\r") + if "=" in x + } + extra_metadata = dict(**extra_metadata, **metadata_dict) + assert metadata == extra_metadata + + +@pytest.mark.parametrize("file_path", file_paths) +def test_parse_metadata(file_path): + metadata = extract_extra_metadata(file_path) + parsed_metadata = parse_metadata(metadata) + sampling_frequency = float(metadata["SI.hRoiManager.scanVolumeRate"]) + num_channels = len(metadata["SI.hChannels.channelsActive"].split(";")) + num_planes = int(metadata["SI.hStackManager.numSlices"]) + frames_per_slice = int(metadata["SI.hStackManager.framesPerSlice"]) + channel_names = metadata["SI.hChannels.channelName"].split("'")[1::2][:num_channels] + assert parsed_metadata == dict( + sampling_frequency=sampling_frequency, + num_channels=num_channels, + num_planes=num_planes, + frames_per_slice=frames_per_slice, + channel_names=channel_names, + ) + + +def test_parse_metadata_v3_8(): + file_path = scan_image_path / "sample_scanimage_version_3_8.tiff" + metadata = extract_extra_metadata(file_path) + parsed_metadata = parse_metadata_v3_8(metadata) + sampling_frequency = float(metadata["state.acq.frameRate"]) + num_channels = int(metadata["state.acq.numberOfChannelsSave"]) + num_planes = int(metadata["state.acq.numberOfZSlices"]) + assert parsed_metadata == dict( + sampling_frequency=sampling_frequency, + num_channels=num_channels, + num_planes=num_planes, + ) + + +@pytest.mark.parametrize("file_path", file_paths) +def test_ScanImageTiffMultiPlaneImagingExtractor__init__(file_path): + extractor = ScanImageTiffMultiPlaneImagingExtractor(file_path=file_path) + assert extractor.file_path == file_path From 36f43f6b9c6a3cc907d37346a639346697a72de6 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Wed, 20 Sep 2023 10:34:13 -0700 Subject: [PATCH 26/47] added note about version support for parse_metadata --- .../tiffimagingextractors/scanimagetiffimagingextractor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index 599bdbbf..e39f3c3c 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -74,6 +74,7 @@ def parse_metadata(metadata): Notes ----- + Known to work on SI version v2019bR0 and v2022.0.0. SI.hChannels.channelsActive = '[1;2;...;N]' where N is the number of active channels. SI.hChannels.channelName = "{'channel_name_1' 'channel_name_2' ... 'channel_name_M'}" where M is the number of channels (active or not). From 67ab8cb8919e657a4eb5a780ad81b7764a54ec5c Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Wed, 20 Sep 2023 10:36:02 -0700 Subject: [PATCH 27/47] added note about version support for extract_extra_metadata --- .../tiffimagingextractors/scanimagetiffimagingextractor.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index e39f3c3c..f462f408 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -38,6 +38,10 @@ def extract_extra_metadata( ------- extra_metadata: dict Dictionary of metadata extracted from the TIFF file. + + Notes + ----- + Known to work on SI versions v3.8.0, v2019bR0, and v2022.0.0. """ ScanImageTiffReader = _get_scanimage_reader() io = ScanImageTiffReader(str(file_path)) @@ -74,7 +78,7 @@ def parse_metadata(metadata): Notes ----- - Known to work on SI version v2019bR0 and v2022.0.0. + Known to work on SI versions v2019bR0 and v2022.0.0. SI.hChannels.channelsActive = '[1;2;...;N]' where N is the number of active channels. SI.hChannels.channelName = "{'channel_name_1' 'channel_name_2' ... 'channel_name_M'}" where M is the number of channels (active or not). From 41bf93b3fc3c49574e2d864492e19a4c74f63ba5 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Wed, 20 Sep 2023 11:33:57 -0700 Subject: [PATCH 28/47] channel --> channel_name --- .../scanimagetiffimagingextractor.py | 23 +++++++++++-------- tests/test_scanimagetiffimagingextractor.py | 12 +++++----- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index f462f408..cff6aa59 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -281,17 +281,19 @@ class ScanImageTiffMultiPlaneImagingExtractor(MultiPlaneImagingExtractor): def __init__( self, file_path: PathType, - channel: Optional[int] = 0, + channel_name: Optional[str] = None, ) -> None: self.file_path = Path(file_path) - self.channel = channel self.metadata = extract_extra_metadata(file_path) parsed_metadata = parse_metadata(self.metadata) num_planes = parsed_metadata["num_planes"] + channel_names = parsed_metadata["channel_names"] + if channel_name is None: + channel_name = channel_names[0] imaging_extractors = [] for plane in range(num_planes): imaging_extractor = ScanImageTiffSinglePlaneImagingExtractor( - file_path=file_path, channel=channel, plane=plane + file_path=file_path, channel_name=channel_name, plane=plane ) imaging_extractors.append(imaging_extractor) super().__init__(imaging_extractors=imaging_extractors) @@ -310,7 +312,7 @@ class ScanImageTiffSinglePlaneImagingExtractor(ImagingExtractor): def __init__( self, file_path: PathType, - channel: Optional[int] = 0, + channel_name: Optional[str] = None, plane: Optional[int] = 0, ) -> None: """Create a ScanImageTiffImagingExtractor instance from a TIFF file produced by ScanImage. @@ -328,14 +330,13 @@ def __init__( ---------- file_path : PathType Path to the TIFF file. - channel : int, optional - Index of the optical channel for this extractor (default=0). + channel_name : str, optional + Name of the channel for this extractor (default=None). plane : int, optional Index of the depth plane for this extractor (default=0). """ super().__init__() self.file_path = Path(file_path) - self.channel = channel self.plane = plane self.metadata = extract_extra_metadata(file_path) parsed_metadata = parse_metadata(self.metadata) @@ -344,8 +345,12 @@ def __init__( self._num_planes = parsed_metadata["num_planes"] self._frames_per_slice = parsed_metadata["frames_per_slice"] self._channel_names = parsed_metadata["channel_names"] - if channel >= self._num_channels: - raise ValueError(f"Channel index ({channel}) exceeds number of channels ({self._num_channels}).") + if channel_name is None: + channel_name = self._channel_names[0] + self.channel_name = channel_name + if channel_name not in self._channel_names: + raise ValueError(f"Channel name ({channel_name}) not found in channel names ({self._channel_names}).") + self.channel = self._channel_names.index(channel_name) if plane >= self._num_planes: raise ValueError(f"Plane index ({plane}) exceeds number of planes ({self._num_planes}).") if self._frames_per_slice != 1: diff --git a/tests/test_scanimagetiffimagingextractor.py b/tests/test_scanimagetiffimagingextractor.py index 0025a1c9..9fb5bf9c 100644 --- a/tests/test_scanimagetiffimagingextractor.py +++ b/tests/test_scanimagetiffimagingextractor.py @@ -40,12 +40,12 @@ def scan_image_tiff_single_plane_imaging_extractor(request): @pytest.fixture( scope="module", params=[ - dict(channel=0, plane=0), - dict(channel=0, plane=1), - dict(channel=0, plane=2), - dict(channel=1, plane=0), - dict(channel=1, plane=1), - dict(channel=1, plane=2), + dict(channel_name="Channel 1", plane=0), + dict(channel_name="Channel 1", plane=1), + dict(channel_name="Channel 1", plane=2), + dict(channel_name="Channel 2", plane=0), + dict(channel_name="Channel 2", plane=1), + dict(channel_name="Channel 2", plane=2), ], ) # Only the adesnik file has many (>2) frames per plane and multiple (2) channels. def scan_image_tiff_single_plane_imaging_extractor_adesnik(request): From 370341ba561b0327e464edc17e070b9e1d1a5944 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Wed, 20 Sep 2023 13:28:46 -0700 Subject: [PATCH 29/47] remove Optional for int plane --- .../tiffimagingextractors/scanimagetiffimagingextractor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index cff6aa59..14ee0a2d 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -313,7 +313,7 @@ def __init__( self, file_path: PathType, channel_name: Optional[str] = None, - plane: Optional[int] = 0, + plane: int = 0, ) -> None: """Create a ScanImageTiffImagingExtractor instance from a TIFF file produced by ScanImage. From 9831e5d0a30cd20d80872fb9b7bf27152046c62f Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Wed, 20 Sep 2023 17:09:05 -0700 Subject: [PATCH 30/47] plane --> plane_name --- .../scanimagetiffimagingextractor.py | 61 +++++++++++++++---- tests/test_scanimagetiffimagingextractor.py | 14 ++--- 2 files changed, 57 insertions(+), 18 deletions(-) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index 14ee0a2d..089733c3 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -293,7 +293,7 @@ def __init__( imaging_extractors = [] for plane in range(num_planes): imaging_extractor = ScanImageTiffSinglePlaneImagingExtractor( - file_path=file_path, channel_name=channel_name, plane=plane + file_path=file_path, channel_name=channel_name, plane_name=str(plane) ) imaging_extractors.append(imaging_extractor) super().__init__(imaging_extractors=imaging_extractors) @@ -309,11 +309,50 @@ class ScanImageTiffSinglePlaneImagingExtractor(ImagingExtractor): is_writable = True mode = "file" + @classmethod + def get_channel_names(cls, file_path): + """Get the channel names from a TIFF file produced by ScanImage. + + Parameters + ---------- + file_path : PathType + Path to the TIFF file. + + Returns + ------- + channel_names: list + List of channel names. + """ + metadata = extract_extra_metadata(file_path) + parsed_metadata = parse_metadata(metadata) + channel_names = parsed_metadata["channel_names"] + return channel_names + + @classmethod + def get_plane_names(cls, file_path): + """Get the plane names from a TIFF file produced by ScanImage. + + Parameters + ---------- + file_path : PathType + Path to the TIFF file. + + Returns + ------- + plane_names: list + List of plane names. + """ + metadata = extract_extra_metadata(file_path) + parsed_metadata = parse_metadata(metadata) + num_planes = parsed_metadata["num_planes"] + plane_names = [f"{i}" for i in range(num_planes)] + return plane_names + def __init__( self, file_path: PathType, - channel_name: Optional[str] = None, - plane: int = 0, + channel_name: str, + plane_name: str, ) -> None: """Create a ScanImageTiffImagingExtractor instance from a TIFF file produced by ScanImage. @@ -330,14 +369,13 @@ def __init__( ---------- file_path : PathType Path to the TIFF file. - channel_name : str, optional + channel_name : str Name of the channel for this extractor (default=None). - plane : int, optional - Index of the depth plane for this extractor (default=0). + plane_name : str + Name of the plane for this extractor (default=None). """ super().__init__() self.file_path = Path(file_path) - self.plane = plane self.metadata = extract_extra_metadata(file_path) parsed_metadata = parse_metadata(self.metadata) self._sampling_frequency = parsed_metadata["sampling_frequency"] @@ -345,14 +383,15 @@ def __init__( self._num_planes = parsed_metadata["num_planes"] self._frames_per_slice = parsed_metadata["frames_per_slice"] self._channel_names = parsed_metadata["channel_names"] - if channel_name is None: - channel_name = self._channel_names[0] + self._plane_names = [f"{i}" for i in range(self._num_planes)] self.channel_name = channel_name + self.plane_name = plane_name if channel_name not in self._channel_names: raise ValueError(f"Channel name ({channel_name}) not found in channel names ({self._channel_names}).") self.channel = self._channel_names.index(channel_name) - if plane >= self._num_planes: - raise ValueError(f"Plane index ({plane}) exceeds number of planes ({self._num_planes}).") + if plane_name not in self._plane_names: + raise ValueError(f"Plane name ({plane_name}) not found in plane names ({self._plane_names}).") + self.plane = self._plane_names.index(plane_name) if self._frames_per_slice != 1: warn( "Multiple frames per slice have not been tested and may produce incorrect output. " diff --git a/tests/test_scanimagetiffimagingextractor.py b/tests/test_scanimagetiffimagingextractor.py index 9fb5bf9c..583caa7c 100644 --- a/tests/test_scanimagetiffimagingextractor.py +++ b/tests/test_scanimagetiffimagingextractor.py @@ -34,18 +34,18 @@ def metadata_string_to_dict(metadata_string): @pytest.fixture(scope="module", params=file_paths) def scan_image_tiff_single_plane_imaging_extractor(request): - return ScanImageTiffSinglePlaneImagingExtractor(file_path=request.param) + return ScanImageTiffSinglePlaneImagingExtractor(file_path=request.param, channel_name="Channel 1", plane_name="0") @pytest.fixture( scope="module", params=[ - dict(channel_name="Channel 1", plane=0), - dict(channel_name="Channel 1", plane=1), - dict(channel_name="Channel 1", plane=2), - dict(channel_name="Channel 2", plane=0), - dict(channel_name="Channel 2", plane=1), - dict(channel_name="Channel 2", plane=2), + dict(channel_name="Channel 1", plane_name="0"), + dict(channel_name="Channel 1", plane_name="1"), + dict(channel_name="Channel 1", plane_name="2"), + dict(channel_name="Channel 2", plane_name="0"), + dict(channel_name="Channel 2", plane_name="1"), + dict(channel_name="Channel 2", plane_name="2"), ], ) # Only the adesnik file has many (>2) frames per plane and multiple (2) channels. def scan_image_tiff_single_plane_imaging_extractor_adesnik(request): From c410cac20d04ce1d340e7981fa5d5fed9ed6e734 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Wed, 20 Sep 2023 17:09:40 -0700 Subject: [PATCH 31/47] remove temp test --- tests/temp_test_scanimage.py | 46 ------------------------------------ 1 file changed, 46 deletions(-) delete mode 100644 tests/temp_test_scanimage.py diff --git a/tests/temp_test_scanimage.py b/tests/temp_test_scanimage.py deleted file mode 100644 index 22ff3ffa..00000000 --- a/tests/temp_test_scanimage.py +++ /dev/null @@ -1,46 +0,0 @@ -# scratch file to test scanimage tiff extractor -from roiextractors import ScanImageTiffImagingExtractor, MultiImagingExtractor -from roiextractors.extractors.tiffimagingextractors.scanimagetiffimagingextractor import ( - extract_extra_metadata, - parse_metadata, - ScanImageTiffMultiPlaneImagingExtractor, -) - - -def main(): - example_test = "/Users/pauladkisson/Documents/CatalystNeuro/ROIExtractors/ophys_testing_data/imaging_datasets/ScanImage/sample_scanimage_version_3_8.tiff" - example_holo = "/Volumes/T7/CatalystNeuro/NWB/MouseV1/raw-tiffs/2ret/20230119_w57_1_2ret_00001.tif" - example_single = "/Users/pauladkisson/Documents/CatalystNeuro/ROIExtractors/ophys_testing_data/imaging_datasets/ScanImage/scanimage_20220801_single.tif" - example_volume = "/Users/pauladkisson/Documents/CatalystNeuro/ROIExtractors/ophys_testing_data/imaging_datasets/ScanImage/scanimage_20220801_volume.tif" - example_multivolume = "/Users/pauladkisson/Documents/CatalystNeuro/ROIExtractors/ophys_testing_data/imaging_datasets/ScanImage/scanimage_20220801_multivolume.tif" - multi_example_holo = [ - f"/Volumes/T7/CatalystNeuro/NWB/MouseV1/raw-tiffs/2ret/20230119_w57_1_2ret_{i:05d}.tif" for i in range(1, 11) - ] - - extractor = ScanImageTiffImagingExtractor(file_path=example_test, sampling_frequency=30.0) - print("Example test file loads!") - - extractor = ScanImageTiffMultiPlaneImagingExtractor(file_path=example_holo) - print("Example holographic file loads!") - video = extractor.get_video() - print("Video shape:", video.shape) - - extractors = [ScanImageTiffMultiPlaneImagingExtractor(file_path=f) for f in multi_example_holo] - extractor = MultiImagingExtractor(imaging_extractors=extractors) - print("Example multi-holographic file loads!") - video = extractor.get_video() - print("Video shape:", video.shape) - - extractor = ScanImageTiffMultiPlaneImagingExtractor(file_path=example_volume) - print("Example volume file loads!") - video = extractor.get_video() - print("Video shape:", video.shape) - - extractor = ScanImageTiffMultiPlaneImagingExtractor(file_path=example_multivolume) - print("Example multi-volume file loads!") - video = extractor.get_video() - print("Video shape:", video.shape) - - -if __name__ == "__main__": - main() From ee4dc1a7310c190834b953bcc416db5983ead8ed Mon Sep 17 00:00:00 2001 From: Paul Adkisson Date: Wed, 20 Sep 2023 20:37:12 -0400 Subject: [PATCH 32/47] Update tests/test_scanimagetiffimagingextractor.py Co-authored-by: Cody Baker <51133164+CodyCBakerPhD@users.noreply.github.com> --- tests/test_scanimagetiffimagingextractor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_scanimagetiffimagingextractor.py b/tests/test_scanimagetiffimagingextractor.py index 583caa7c..375a7395 100644 --- a/tests/test_scanimagetiffimagingextractor.py +++ b/tests/test_scanimagetiffimagingextractor.py @@ -88,7 +88,7 @@ def test_get_frames_adesnik( assert_array_equal(frames, io.data()[raw_idxs]) -def test__get_single_frame(scan_image_tiff_single_plane_imaging_extractor): +def test_get_single_frame(scan_image_tiff_single_plane_imaging_extractor): frame = scan_image_tiff_single_plane_imaging_extractor._get_single_frame(frame=0) file_path = str(scan_image_tiff_single_plane_imaging_extractor.file_path) with ScanImageTiffReader(file_path) as io: From a36e753a06846453aafc5fda04b6cb5d216ada67 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Wed, 20 Sep 2023 18:06:32 -0700 Subject: [PATCH 33/47] added input validation tests to get_frames and get_video --- .../scanimagetiffimagingextractor.py | 3 ++ tests/test_scanimagetiffimagingextractor.py | 49 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index 089733c3..1f97fdea 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -481,6 +481,7 @@ def get_video(self, start_frame=None, end_frame=None) -> np.ndarray: if end_frame is None: end_frame = self._num_frames self.check_frame_inputs(end_frame - 1) + self.check_frame_inputs(start_frame) ScanImageTiffReader = _get_scanimage_reader() raw_start = self.frame_to_raw_index(start_frame) raw_end = self.frame_to_raw_index(end_frame) @@ -515,6 +516,8 @@ def get_dtype(self) -> DtypeType: def check_frame_inputs(self, frame) -> None: if frame >= self._num_frames: raise ValueError(f"Frame index ({frame}) exceeds number of frames ({self._num_frames}).") + if frame < 0: + raise ValueError(f"Frame index ({frame}) must be greater than or equal to 0.") def frame_to_raw_index(self, frame): """Convert a frame index to the raw index in the TIFF file. diff --git a/tests/test_scanimagetiffimagingextractor.py b/tests/test_scanimagetiffimagingextractor.py index 375a7395..efb8604d 100644 --- a/tests/test_scanimagetiffimagingextractor.py +++ b/tests/test_scanimagetiffimagingextractor.py @@ -88,6 +88,12 @@ def test_get_frames_adesnik( assert_array_equal(frames, io.data()[raw_idxs]) +@pytest.mark.parametrize("frame_idxs", ([-1], [50])) +def test_get_frames_adesnik_invalid(scan_image_tiff_single_plane_imaging_extractor_adesnik, frame_idxs): + with pytest.raises(ValueError): + scan_image_tiff_single_plane_imaging_extractor_adesnik.get_frames(frame_idxs=frame_idxs) + + def test_get_single_frame(scan_image_tiff_single_plane_imaging_extractor): frame = scan_image_tiff_single_plane_imaging_extractor._get_single_frame(frame=0) file_path = str(scan_image_tiff_single_plane_imaging_extractor.file_path) @@ -95,6 +101,26 @@ def test_get_single_frame(scan_image_tiff_single_plane_imaging_extractor): assert_array_equal(frame, io.data()[:1]) +@pytest.mark.parametrize("frame_idx", (5, 10, 31)) +def test_get_single_frame_adesnik( + scan_image_tiff_single_plane_imaging_extractor_adesnik, num_planes_adesnik, num_channels_adesnik, frame_idx +): + frame = scan_image_tiff_single_plane_imaging_extractor_adesnik._get_single_frame(frame=frame_idx) + file_path = str(scan_image_tiff_single_plane_imaging_extractor_adesnik.file_path) + plane = scan_image_tiff_single_plane_imaging_extractor_adesnik.plane + channel = scan_image_tiff_single_plane_imaging_extractor_adesnik.channel + raw_idx = frame_idx * num_planes_adesnik * num_channels_adesnik + plane * num_channels_adesnik + channel + print(raw_idx) + with ScanImageTiffReader(file_path) as io: + assert_array_equal(frame, io.data()[raw_idx : raw_idx + 1]) + + +@pytest.mark.parametrize("frame", (-1, 50)) +def test_get_single_frame_adesnik_invalid(scan_image_tiff_single_plane_imaging_extractor_adesnik, frame): + with pytest.raises(ValueError): + scan_image_tiff_single_plane_imaging_extractor_adesnik._get_single_frame(frame=frame) + + def test_get_video(scan_image_tiff_single_plane_imaging_extractor): video = scan_image_tiff_single_plane_imaging_extractor.get_video() file_path = str(scan_image_tiff_single_plane_imaging_extractor.file_path) @@ -126,6 +152,16 @@ def test_get_video_adesnik( assert_array_equal(video, io.data()[raw_idxs]) +@pytest.mark.parametrize("start_frame, end_frame", [(-1, 2), (0, 50)]) +def test_get_video_adesnik_invalid( + scan_image_tiff_single_plane_imaging_extractor_adesnik, + start_frame, + end_frame, +): + with pytest.raises(ValueError): + scan_image_tiff_single_plane_imaging_extractor_adesnik.get_video(start_frame=start_frame, end_frame=end_frame) + + def test_get_image_size(scan_image_tiff_single_plane_imaging_extractor): image_size = scan_image_tiff_single_plane_imaging_extractor.get_image_size() file_path = str(scan_image_tiff_single_plane_imaging_extractor.file_path) @@ -257,3 +293,16 @@ def test_parse_metadata_v3_8(): def test_ScanImageTiffMultiPlaneImagingExtractor__init__(file_path): extractor = ScanImageTiffMultiPlaneImagingExtractor(file_path=file_path) assert extractor.file_path == file_path + + +@pytest.mark.parametrize("channel_name, plane_name", [("Invalid Channel", "0"), ("Channel 1", "Invalid Plane")]) +def test_ScanImageTiffSinglePlaneImagingExtractor__init__invalid(channel_name, plane_name): + with pytest.raises(ValueError): + ScanImageTiffSinglePlaneImagingExtractor( + file_path=file_paths[0], channel_name=channel_name, plane_name=plane_name + ) + + +def test_ScanImageTiffMultiPlaneImagingExtractor__init__invalid(): + with pytest.raises(ValueError): + ScanImageTiffMultiPlaneImagingExtractor(file_path=file_paths[0], channel_name="Invalid Channel") From bd1070d7759940678631feafb402f1a68eeb36f4 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Fri, 22 Sep 2023 10:42:31 -0700 Subject: [PATCH 34/47] removed redundant get_channel_names --- .../tiffimagingextractors/scanimagetiffimagingextractor.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index 1f97fdea..5fbdeb9e 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -504,9 +504,6 @@ def get_sampling_frequency(self) -> float: def get_num_channels(self) -> int: return self._num_channels - def get_channel_names(self) -> list: - return self._channel_names - def get_num_planes(self) -> int: return self._num_planes From cbb1e031badb3dc52c30e21c40e170243a7a24e5 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Fri, 22 Sep 2023 11:58:54 -0700 Subject: [PATCH 35/47] temp removed get_channel_names from consistency checks AND fixed bug in parse_metadata for various types of activeChannels --- .../scanimagetiffimagingextractor.py | 47 +++++++++++++++++-- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index 5fbdeb9e..ea7853a7 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -56,15 +56,51 @@ def extract_extra_metadata( return extra_metadata +def parse_matlab_vector(matlab_vector: str) -> list: + """Parse a MATLAB vector string into a list of integer values. + + Parameters + ---------- + matlab_vector : str + MATLAB vector string. + + Returns + ------- + vector: list of int + List of integer values. + + Raises + ------ + ValueError + If the MATLAB vector string cannot be parsed. + + Notes + ----- + MATLAB vector string is of the form "[1 2 3 ... N]" or "[1,2,3,...,N]" or "[1;2;3;...;N]". + There may or may not be whitespace between the values. Ex. "[1, 2, 3]" or "[1,2,3]". + """ + vector = matlab_vector.strip("[]") + if ";" in vector: + vector = vector.split(";") + elif "," in vector: + vector = vector.split(",") + elif " " in vector: + vector = vector.split(" ") + else: + raise ValueError(f"Could not parse vector from {matlab_vector}.") + vector = [int(x.strip()) for x in vector if x != ""] + return vector + + def parse_metadata(metadata): """Parse metadata dictionary to extract relevant information and store it standard keys for ImagingExtractors. Currently supports - sampling_frequency - - num_channels - num_planes - frames_per_slice - channel_names + - num_channels Parameters ---------- @@ -84,10 +120,13 @@ def parse_metadata(metadata): where M is the number of channels (active or not). """ sampling_frequency = float(metadata["SI.hRoiManager.scanVolumeRate"]) - num_channels = len(metadata["SI.hChannels.channelsActive"].split(";")) num_planes = int(metadata["SI.hStackManager.numSlices"]) frames_per_slice = int(metadata["SI.hStackManager.framesPerSlice"]) - channel_names = metadata["SI.hChannels.channelName"].split("'")[1::2][:num_channels] + active_channels = parse_matlab_vector(metadata["SI.hChannels.channelsActive"]) + channel_indices = np.array(active_channels) - 1 # Account for MATLAB indexing + channel_names = np.array(metadata["SI.hChannels.channelName"].split("'")[1::2]) + channel_names = channel_names[channel_indices].tolist() + num_channels = len(channel_names) metadata_parsed = dict( sampling_frequency=sampling_frequency, num_channels=num_channels, @@ -150,6 +189,7 @@ def __init__(self, imaging_extractors: List[ImagingExtractor]): self._imaging_extractors = imaging_extractors self._num_planes = len(imaging_extractors) + # TODO: Add consistency check for channel_names when API is standardized def _check_consistency_between_imaging_extractors(self, imaging_extractors: List[ImagingExtractor]): """Check that essential properties are consistent between extractors so that they can be combined appropriately. @@ -177,7 +217,6 @@ def _check_consistency_between_imaging_extractors(self, imaging_extractors: List get_sampling_frequency="The sampling frequency", get_image_size="The size of a frame", get_num_channels="The number of channels", - get_channel_names="The name of the channels", get_dtype="The data type", get_num_frames="The number of frames", ) From 3bc556dfa605341ccc543c71bc0bd3d25555fba8 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Fri, 22 Sep 2023 12:00:36 -0700 Subject: [PATCH 36/47] updated parse_metadata docstring --- .../tiffimagingextractors/scanimagetiffimagingextractor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index ea7853a7..22ab617d 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -115,7 +115,7 @@ def parse_metadata(metadata): Notes ----- Known to work on SI versions v2019bR0 and v2022.0.0. - SI.hChannels.channelsActive = '[1;2;...;N]' where N is the number of active channels. + SI.hChannels.channelsActive = string of MATLAB-style vector with channel integers (see parse_matlab_vector). SI.hChannels.channelName = "{'channel_name_1' 'channel_name_2' ... 'channel_name_M'}" where M is the number of channels (active or not). """ From 36105b9a7a6e6497cb3ae346775c7622e27476a6 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Fri, 22 Sep 2023 13:53:46 -0700 Subject: [PATCH 37/47] added support for multiple frames per slice --- .../scanimagetiffimagingextractor.py | 57 +++++++++++++------ tests/test_scanimagetiffimagingextractor.py | 13 ++--- 2 files changed, 44 insertions(+), 26 deletions(-) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index 22ab617d..e7feb5cb 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -86,6 +86,8 @@ def parse_matlab_vector(matlab_vector: str) -> list: vector = vector.split(",") elif " " in vector: vector = vector.split(" ") + elif len(vector) == 1: + pass else: raise ValueError(f"Could not parse vector from {matlab_vector}.") vector = [int(x.strip()) for x in vector if x != ""] @@ -398,10 +400,15 @@ def __init__( The underlying data is stored in a round-robin format collapsed into 3 dimensions (frames, rows, columns). I.e. the first frame of each channel and each plane is stored, and then the second frame of each channel and each plane, etc. - Ex. for 2 channels and 2 planes: - [channel_1_plane_1_frame_1, channel_2_plane_1_frame_1, channel_1_plane_2_frame_1, channel_2_plane_2_frame_1, - channel_1_plane_1_frame_2, channel_2_plane_1_frame_2, channel_1_plane_2_frame_2, channel_2_plane_2_frame_2, ... - channel_1_plane_1_frame_N, channel_2_plane_1_frame_N, channel_1_plane_2_frame_N, channel_2_plane_2_frame_N] + If framesPerSlice > 1, then multiple frames are acquired per slice before moving to the next slice. + Ex. for 2 channels, 2 planes, and 2 framesPerSlice: + ``` + [channel_1_plane_1_frame_1, channel_2_plane_1_frame_1, channel_1_plane_1_frame_2, channel_2_plane_1_frame_2, + channel_1_plane_2_frame_1, channel_2_plane_2_frame_1, channel_1_plane_2_frame_2, channel_2_plane_2_frame_2, + channel_1_plane_1_frame_3, channel_2_plane_1_frame_3, channel_1_plane_1_frame_4, channel_2_plane_1_frame_4, + channel_1_plane_2_frame_3, channel_2_plane_2_frame_3, channel_1_plane_2_frame_4, channel_2_plane_2_frame_4, ... + channel_1_plane_1_frame_N, channel_2_plane_1_frame_N, channel_1_plane_2_frame_N, channel_2_plane_2_frame_N] + ``` This file structured is accessed by ScanImageTiffImagingExtractor for a single channel and plane. Parameters @@ -431,12 +438,6 @@ def __init__( if plane_name not in self._plane_names: raise ValueError(f"Plane name ({plane_name}) not found in plane names ({self._plane_names}).") self.plane = self._plane_names.index(plane_name) - if self._frames_per_slice != 1: - warn( - "Multiple frames per slice have not been tested and may produce incorrect output. " - "Please raise an issue to request this feature: " - "https://github.com/catalystneuro/roiextractors/issues " - ) valid_suffixes = [".tiff", ".tif", ".TIFF", ".TIF"] if self.file_path.suffix not in valid_suffixes: @@ -450,10 +451,13 @@ def __init__( shape = io.shape() # [frames, rows, columns] if len(shape) == 3: self._total_num_frames, self._num_rows, self._num_columns = shape + self._num_raw_per_plane = self._frames_per_slice * self._num_channels + self._num_raw_per_cycle = self._num_raw_per_plane * self._num_planes self._num_frames = self._total_num_frames // (self._num_planes * self._num_channels) + self._num_cycles = self._total_num_frames // self._num_raw_per_cycle else: raise NotImplementedError( - "Extractor cannot handle 4D TIFF data. Please raise an issue to request this feature: " + "Extractor cannot handle 4D ScanImageTiff data. Please raise an issue to request this feature: " "https://github.com/catalystneuro/roiextractors/issues " ) @@ -527,8 +531,13 @@ def get_video(self, start_frame=None, end_frame=None) -> np.ndarray: raw_end = np.min([raw_end, self._total_num_frames]) with ScanImageTiffReader(filename=str(self.file_path)) as io: raw_video = io.data(beg=raw_start, end=raw_end) - video = raw_video[:: self._num_channels] - video = video[:: self._num_planes] + start_cycle = raw_start // self._num_raw_per_cycle + end_cycle = raw_end // self._num_raw_per_cycle + index = [] + for i in range(end_cycle - start_cycle): + for j in range(self._frames_per_slice): + index.append(i * self._num_raw_per_cycle + j * self._num_channels) + video = raw_video[index] return video def get_image_size(self) -> Tuple[int, int]: @@ -573,12 +582,24 @@ def frame_to_raw_index(self, frame): The underlying data is stored in a round-robin format collapsed into 3 dimensions (frames, rows, columns). I.e. the first frame of each channel and each plane is stored, and then the second frame of each channel and each plane, etc. - Ex. for 2 channels and 2 planes: - [channel_1_plane_1_frame_1, channel_2_plane_1_frame_1, channel_1_plane_2_frame_1, channel_2_plane_2_frame_1, - channel_1_plane_1_frame_2, channel_2_plane_1_frame_2, channel_1_plane_2_frame_2, channel_2_plane_2_frame_2, ... - channel_1_plane_1_frame_N, channel_2_plane_1_frame_N, channel_1_plane_2_frame_N, channel_2_plane_2_frame_N] + If framesPerSlice > 1, then multiple frames are acquired per slice before moving to the next slice. + Ex. for 2 channels, 2 planes, and 2 framesPerSlice: + ``` + [channel_1_plane_1_frame_1, channel_2_plane_1_frame_1, channel_1_plane_1_frame_2, channel_2_plane_1_frame_2, + channel_1_plane_2_frame_1, channel_2_plane_2_frame_1, channel_1_plane_2_frame_2, channel_2_plane_2_frame_2, + channel_1_plane_1_frame_3, channel_2_plane_1_frame_3, channel_1_plane_1_frame_4, channel_2_plane_1_frame_4, + channel_1_plane_2_frame_3, channel_2_plane_2_frame_3, channel_1_plane_2_frame_4, channel_2_plane_2_frame_4, ... + channel_1_plane_1_frame_N, channel_2_plane_1_frame_N, channel_1_plane_2_frame_N, channel_2_plane_2_frame_N] + ``` """ - raw_index = (frame * self._num_planes * self._num_channels) + (self.plane * self._num_channels) + self.channel + cycle = frame // self._frames_per_slice + frame_in_cycle = frame % self._frames_per_slice + raw_index = ( + cycle * self._num_raw_per_cycle + + self.plane * self._num_raw_per_plane + + frame_in_cycle * self._num_channels + + self.channel + ) return raw_index diff --git a/tests/test_scanimagetiffimagingextractor.py b/tests/test_scanimagetiffimagingextractor.py index efb8604d..36f2dc2b 100644 --- a/tests/test_scanimagetiffimagingextractor.py +++ b/tests/test_scanimagetiffimagingextractor.py @@ -16,8 +16,9 @@ scan_image_path = OPHYS_DATA_PATH / "imaging_datasets" / "ScanImage" test_files = [ - "scanimage_20220801_volume.tif", - "scanimage_20220801_multivolume.tif", + # "scanimage_20220801_volume.tif", + # "scanimage_20220801_multivolume.tif", + # "roi.tif", "scanimage_20230119_adesnik_00001.tif", ] file_paths = [scan_image_path / test_file for test_file in test_files] @@ -197,13 +198,9 @@ def test_get_num_channels(scan_image_tiff_single_plane_imaging_extractor): def test_get_channel_names(scan_image_tiff_single_plane_imaging_extractor): - channel_names = scan_image_tiff_single_plane_imaging_extractor.get_channel_names() - num_channels = scan_image_tiff_single_plane_imaging_extractor.get_num_channels() file_path = str(scan_image_tiff_single_plane_imaging_extractor.file_path) - with ScanImageTiffReader(file_path) as io: - metadata_string = io.metadata() - metadata_dict = metadata_string_to_dict(metadata_string) - assert channel_names == metadata_dict["SI.hChannels.channelName"].split("'")[1::2][:num_channels] + channel_names = ScanImageTiffSinglePlaneImagingExtractor.get_channel_names(file_path) + assert channel_names == ["Channel 1", "Channel 2"] def test_get_num_planes(scan_image_tiff_single_plane_imaging_extractor): From dc54a0369d7049185b8aa604aeae3825f8774732 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Fri, 22 Sep 2023 18:49:58 -0700 Subject: [PATCH 38/47] revamped tests with new data (get_video still has some problems) --- .../scanimagetiffimagingextractor.py | 26 +- tests/test_scanimagetiffimagingextractor.py | 329 +++++++----------- 2 files changed, 149 insertions(+), 206 deletions(-) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index e7feb5cb..66fe065d 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -526,17 +526,31 @@ def get_video(self, start_frame=None, end_frame=None) -> np.ndarray: self.check_frame_inputs(end_frame - 1) self.check_frame_inputs(start_frame) ScanImageTiffReader = _get_scanimage_reader() - raw_start = self.frame_to_raw_index(start_frame) - raw_end = self.frame_to_raw_index(end_frame) - raw_end = np.min([raw_end, self._total_num_frames]) + start_cycle = start_frame // self._frames_per_slice + end_cycle = end_frame // self._frames_per_slice + end_cycle_ceiling = np.ceil(end_frame / self._frames_per_slice).astype(int) + raw_start = self.frame_to_raw_index(start_cycle * self._frames_per_slice) + raw_end = self.frame_to_raw_index(end_cycle_ceiling * self._frames_per_slice) + print(f"{raw_start = }") + print(f"{raw_end = }") with ScanImageTiffReader(filename=str(self.file_path)) as io: raw_video = io.data(beg=raw_start, end=raw_end) - start_cycle = raw_start // self._num_raw_per_cycle - end_cycle = raw_end // self._num_raw_per_cycle + + print(f"{start_cycle = }") + print(f"{end_cycle = }") + start_frame_in_cycle = start_frame % self._frames_per_slice + end_frame_in_cycle = end_frame % self._frames_per_slice + print(f"{start_frame_in_cycle = }") + print(f"{end_frame_in_cycle = }") index = [] for i in range(end_cycle - start_cycle): for j in range(self._frames_per_slice): - index.append(i * self._num_raw_per_cycle + j * self._num_channels) + index.append(i * self._num_raw_per_cycle + ((j + start_frame_in_cycle) * self._num_channels)) + for j in range(end_frame_in_cycle): + index.append( + (end_cycle - start_cycle) * self._num_raw_per_cycle + ((j + start_frame_in_cycle) * self._num_channels) + ) + print(f"index: {index}") video = raw_video[index] return video diff --git a/tests/test_scanimagetiffimagingextractor.py b/tests/test_scanimagetiffimagingextractor.py index 36f2dc2b..1f935e23 100644 --- a/tests/test_scanimagetiffimagingextractor.py +++ b/tests/test_scanimagetiffimagingextractor.py @@ -14,28 +14,24 @@ from .setup_paths import OPHYS_DATA_PATH -scan_image_path = OPHYS_DATA_PATH / "imaging_datasets" / "ScanImage" -test_files = [ - # "scanimage_20220801_volume.tif", - # "scanimage_20220801_multivolume.tif", - # "roi.tif", - "scanimage_20230119_adesnik_00001.tif", -] -file_paths = [scan_image_path / test_file for test_file in test_files] - -def metadata_string_to_dict(metadata_string): - metadata_dict = { - x.split("=")[0].strip(): x.split("=")[1].strip() - for x in metadata_string.replace("\n", "\r").split("\r") - if "=" in x - } - return metadata_dict +@pytest.fixture(scope="module") +def file_path(): + return OPHYS_DATA_PATH / "imaging_datasets" / "ScanImage" / "roi.tif" -@pytest.fixture(scope="module", params=file_paths) -def scan_image_tiff_single_plane_imaging_extractor(request): - return ScanImageTiffSinglePlaneImagingExtractor(file_path=request.param, channel_name="Channel 1", plane_name="0") +@pytest.fixture(scope="module") +def expected_properties(): + return dict( + sampling_frequency=7.28119, + num_channels=2, + num_planes=2, + frames_per_slice=2, + channel_names=["Channel 1", "Channel 4"], + image_size=(528, 256), + num_frames=6, + dtype="int16", + ) @pytest.fixture( @@ -43,263 +39,196 @@ def scan_image_tiff_single_plane_imaging_extractor(request): params=[ dict(channel_name="Channel 1", plane_name="0"), dict(channel_name="Channel 1", plane_name="1"), - dict(channel_name="Channel 1", plane_name="2"), - dict(channel_name="Channel 2", plane_name="0"), - dict(channel_name="Channel 2", plane_name="1"), - dict(channel_name="Channel 2", plane_name="2"), + dict(channel_name="Channel 4", plane_name="0"), + dict(channel_name="Channel 4", plane_name="1"), ], -) # Only the adesnik file has many (>2) frames per plane and multiple (2) channels. -def scan_image_tiff_single_plane_imaging_extractor_adesnik(request): - file_path = scan_image_path / "scanimage_20230119_adesnik_00001.tif" +) +def scan_image_tiff_single_plane_imaging_extractor(request, file_path): return ScanImageTiffSinglePlaneImagingExtractor(file_path=file_path, **request.param) -@pytest.fixture(scope="module") -def num_planes_adesnik(): - return 3 - - -@pytest.fixture(scope="module") -def num_channels_adesnik(): - return 2 +@pytest.mark.parametrize("channel_name, plane_name", [("Invalid Channel", "0"), ("Channel 1", "Invalid Plane")]) +def test_ScanImageTiffSinglePlaneImagingExtractor__init__invalid(file_path, channel_name, plane_name): + with pytest.raises(ValueError): + ScanImageTiffSinglePlaneImagingExtractor(file_path=file_path, channel_name=channel_name, plane_name=plane_name) -@pytest.mark.parametrize("frame_idxs", (0, [0])) -def test_get_frames(scan_image_tiff_single_plane_imaging_extractor, frame_idxs): +@pytest.mark.parametrize("frame_idxs", (0, [0, 1, 2], [0, 2, 5])) +def test_get_frames(scan_image_tiff_single_plane_imaging_extractor, frame_idxs, expected_properties): frames = scan_image_tiff_single_plane_imaging_extractor.get_frames(frame_idxs=frame_idxs) file_path = str(scan_image_tiff_single_plane_imaging_extractor.file_path) - with ScanImageTiffReader(file_path) as io: - if isinstance(frame_idxs, int): - frame_idxs = [frame_idxs] - assert_array_equal(frames, io.data()[frame_idxs]) - - -@pytest.mark.parametrize("frame_idxs", ([0, 1, 2], [1, 3, 31])) # 31 is the last frame in the adesnik file -def test_get_frames_adesnik( - scan_image_tiff_single_plane_imaging_extractor_adesnik, num_planes_adesnik, num_channels_adesnik, frame_idxs -): - frames = scan_image_tiff_single_plane_imaging_extractor_adesnik.get_frames(frame_idxs=frame_idxs) - file_path = str(scan_image_tiff_single_plane_imaging_extractor_adesnik.file_path) - plane = scan_image_tiff_single_plane_imaging_extractor_adesnik.plane - channel = scan_image_tiff_single_plane_imaging_extractor_adesnik.channel - raw_idxs = [ - idx * num_planes_adesnik * num_channels_adesnik + plane * num_channels_adesnik + channel for idx in frame_idxs - ] + plane = scan_image_tiff_single_plane_imaging_extractor.plane + channel = scan_image_tiff_single_plane_imaging_extractor.channel + num_planes = expected_properties["num_planes"] + num_channels = expected_properties["num_channels"] + frames_per_slice = expected_properties["frames_per_slice"] + if isinstance(frame_idxs, int): + frame_idxs = [frame_idxs] + raw_idxs = [] + for idx in frame_idxs: + cycle = idx // frames_per_slice + frame_in_cycle = idx % frames_per_slice + raw_idx = ( + cycle * num_planes * num_channels * frames_per_slice + + plane * num_channels * frames_per_slice + + num_channels * frame_in_cycle + + channel + ) + raw_idxs.append(raw_idx) with ScanImageTiffReader(file_path) as io: assert_array_equal(frames, io.data()[raw_idxs]) @pytest.mark.parametrize("frame_idxs", ([-1], [50])) -def test_get_frames_adesnik_invalid(scan_image_tiff_single_plane_imaging_extractor_adesnik, frame_idxs): +def test_get_frames_invalid(scan_image_tiff_single_plane_imaging_extractor, frame_idxs): with pytest.raises(ValueError): - scan_image_tiff_single_plane_imaging_extractor_adesnik.get_frames(frame_idxs=frame_idxs) + scan_image_tiff_single_plane_imaging_extractor.get_frames(frame_idxs=frame_idxs) -def test_get_single_frame(scan_image_tiff_single_plane_imaging_extractor): - frame = scan_image_tiff_single_plane_imaging_extractor._get_single_frame(frame=0) +@pytest.mark.parametrize("frame_idx", (1, 3, 5)) +def test_get_single_frame(scan_image_tiff_single_plane_imaging_extractor, expected_properties, frame_idx): + frame = scan_image_tiff_single_plane_imaging_extractor._get_single_frame(frame=frame_idx) file_path = str(scan_image_tiff_single_plane_imaging_extractor.file_path) - with ScanImageTiffReader(file_path) as io: - assert_array_equal(frame, io.data()[:1]) - - -@pytest.mark.parametrize("frame_idx", (5, 10, 31)) -def test_get_single_frame_adesnik( - scan_image_tiff_single_plane_imaging_extractor_adesnik, num_planes_adesnik, num_channels_adesnik, frame_idx -): - frame = scan_image_tiff_single_plane_imaging_extractor_adesnik._get_single_frame(frame=frame_idx) - file_path = str(scan_image_tiff_single_plane_imaging_extractor_adesnik.file_path) - plane = scan_image_tiff_single_plane_imaging_extractor_adesnik.plane - channel = scan_image_tiff_single_plane_imaging_extractor_adesnik.channel - raw_idx = frame_idx * num_planes_adesnik * num_channels_adesnik + plane * num_channels_adesnik + channel - print(raw_idx) + plane = scan_image_tiff_single_plane_imaging_extractor.plane + channel = scan_image_tiff_single_plane_imaging_extractor.channel + num_planes = expected_properties["num_planes"] + num_channels = expected_properties["num_channels"] + frames_per_slice = expected_properties["frames_per_slice"] + cycle = frame_idx // frames_per_slice + frame_in_cycle = frame_idx % frames_per_slice + raw_idx = ( + cycle * num_planes * num_channels * frames_per_slice + + plane * num_channels * frames_per_slice + + num_channels * frame_in_cycle + + channel + ) with ScanImageTiffReader(file_path) as io: assert_array_equal(frame, io.data()[raw_idx : raw_idx + 1]) @pytest.mark.parametrize("frame", (-1, 50)) -def test_get_single_frame_adesnik_invalid(scan_image_tiff_single_plane_imaging_extractor_adesnik, frame): +def test_get_single_frame_invalid(scan_image_tiff_single_plane_imaging_extractor, frame): with pytest.raises(ValueError): - scan_image_tiff_single_plane_imaging_extractor_adesnik._get_single_frame(frame=frame) - - -def test_get_video(scan_image_tiff_single_plane_imaging_extractor): - video = scan_image_tiff_single_plane_imaging_extractor.get_video() - file_path = str(scan_image_tiff_single_plane_imaging_extractor.file_path) - num_channels = scan_image_tiff_single_plane_imaging_extractor.get_num_channels() - num_planes = scan_image_tiff_single_plane_imaging_extractor.get_num_planes() - with ScanImageTiffReader(file_path) as io: - assert_array_equal(video, io.data()[:: num_planes * num_channels]) + scan_image_tiff_single_plane_imaging_extractor._get_single_frame(frame=frame) -@pytest.mark.parametrize("start_frame, end_frame", [(0, 2), (5, 10), (20, 32)]) -def test_get_video_adesnik( - scan_image_tiff_single_plane_imaging_extractor_adesnik, - num_planes_adesnik, - num_channels_adesnik, +@pytest.mark.parametrize("start_frame, end_frame", [(0, None), (None, 6), (1, 4), (0, 6)]) +def test_get_video( + scan_image_tiff_single_plane_imaging_extractor, + expected_properties, start_frame, end_frame, ): - video = scan_image_tiff_single_plane_imaging_extractor_adesnik.get_video( - start_frame=start_frame, end_frame=end_frame - ) - file_path = str(scan_image_tiff_single_plane_imaging_extractor_adesnik.file_path) - plane = scan_image_tiff_single_plane_imaging_extractor_adesnik.plane - channel = scan_image_tiff_single_plane_imaging_extractor_adesnik.channel - raw_idxs = [ - idx * num_planes_adesnik * num_channels_adesnik + plane * num_channels_adesnik + channel - for idx in range(start_frame, end_frame) - ] + video = scan_image_tiff_single_plane_imaging_extractor.get_video(start_frame=start_frame, end_frame=end_frame) + if start_frame is None: + start_frame = 0 + if end_frame is None: + end_frame = expected_properties["num_frames"] + file_path = str(scan_image_tiff_single_plane_imaging_extractor.file_path) + plane = scan_image_tiff_single_plane_imaging_extractor.plane + channel = scan_image_tiff_single_plane_imaging_extractor.channel + num_planes = expected_properties["num_planes"] + num_channels = expected_properties["num_channels"] + frames_per_slice = expected_properties["frames_per_slice"] + raw_idxs = [] + for idx in range(start_frame, end_frame): + cycle = idx // frames_per_slice + frame_in_cycle = idx % frames_per_slice + raw_idx = ( + cycle * num_planes * num_channels * frames_per_slice + + plane * num_channels * frames_per_slice + + num_channels * frame_in_cycle + + channel + ) + raw_idxs.append(raw_idx) with ScanImageTiffReader(file_path) as io: assert_array_equal(video, io.data()[raw_idxs]) @pytest.mark.parametrize("start_frame, end_frame", [(-1, 2), (0, 50)]) -def test_get_video_adesnik_invalid( - scan_image_tiff_single_plane_imaging_extractor_adesnik, +def test_get_video_invalid( + scan_image_tiff_single_plane_imaging_extractor, start_frame, end_frame, ): with pytest.raises(ValueError): - scan_image_tiff_single_plane_imaging_extractor_adesnik.get_video(start_frame=start_frame, end_frame=end_frame) + scan_image_tiff_single_plane_imaging_extractor.get_video(start_frame=start_frame, end_frame=end_frame) -def test_get_image_size(scan_image_tiff_single_plane_imaging_extractor): +def test_get_image_size(scan_image_tiff_single_plane_imaging_extractor, expected_properties): image_size = scan_image_tiff_single_plane_imaging_extractor.get_image_size() - file_path = str(scan_image_tiff_single_plane_imaging_extractor.file_path) - with ScanImageTiffReader(file_path) as io: - assert image_size == tuple(io.shape()[1:]) + assert image_size == expected_properties["image_size"] -def test_get_num_frames(scan_image_tiff_single_plane_imaging_extractor): +def test_get_num_frames(scan_image_tiff_single_plane_imaging_extractor, expected_properties): num_frames = scan_image_tiff_single_plane_imaging_extractor.get_num_frames() - num_channels = scan_image_tiff_single_plane_imaging_extractor.get_num_channels() - num_planes = scan_image_tiff_single_plane_imaging_extractor.get_num_planes() - file_path = str(scan_image_tiff_single_plane_imaging_extractor.file_path) - with ScanImageTiffReader(file_path) as io: - assert num_frames == io.shape()[0] // (num_channels * num_planes) + assert num_frames == expected_properties["num_frames"] -def test_get_sampling_frequency(scan_image_tiff_single_plane_imaging_extractor): +def test_get_sampling_frequency(scan_image_tiff_single_plane_imaging_extractor, expected_properties): sampling_frequency = scan_image_tiff_single_plane_imaging_extractor.get_sampling_frequency() - file_path = str(scan_image_tiff_single_plane_imaging_extractor.file_path) - with ScanImageTiffReader(file_path) as io: - metadata_string = io.metadata() - metadata_dict = metadata_string_to_dict(metadata_string) - assert sampling_frequency == float(metadata_dict["SI.hRoiManager.scanVolumeRate"]) + assert sampling_frequency == expected_properties["sampling_frequency"] -def test_get_num_channels(scan_image_tiff_single_plane_imaging_extractor): +def test_get_num_channels(scan_image_tiff_single_plane_imaging_extractor, expected_properties): num_channels = scan_image_tiff_single_plane_imaging_extractor.get_num_channels() - file_path = str(scan_image_tiff_single_plane_imaging_extractor.file_path) - with ScanImageTiffReader(file_path) as io: - metadata_string = io.metadata() - metadata_dict = metadata_string_to_dict(metadata_string) - assert num_channels == len(metadata_dict["SI.hChannels.channelsActive"].split(";")) + assert num_channels == expected_properties["num_channels"] -def test_get_channel_names(scan_image_tiff_single_plane_imaging_extractor): +def test_get_channel_names(scan_image_tiff_single_plane_imaging_extractor, expected_properties): file_path = str(scan_image_tiff_single_plane_imaging_extractor.file_path) channel_names = ScanImageTiffSinglePlaneImagingExtractor.get_channel_names(file_path) - assert channel_names == ["Channel 1", "Channel 2"] + assert channel_names == expected_properties["channel_names"] -def test_get_num_planes(scan_image_tiff_single_plane_imaging_extractor): +def test_get_num_planes(scan_image_tiff_single_plane_imaging_extractor, expected_properties): num_planes = scan_image_tiff_single_plane_imaging_extractor.get_num_planes() - file_path = str(scan_image_tiff_single_plane_imaging_extractor.file_path) - with ScanImageTiffReader(file_path) as io: - metadata_string = io.metadata() - metadata_dict = metadata_string_to_dict(metadata_string) - assert num_planes == int(metadata_dict["SI.hStackManager.numSlices"]) + assert num_planes == expected_properties["num_planes"] -def test_get_dtype(scan_image_tiff_single_plane_imaging_extractor): +def test_get_dtype(scan_image_tiff_single_plane_imaging_extractor, expected_properties): dtype = scan_image_tiff_single_plane_imaging_extractor.get_dtype() - file_path = str(scan_image_tiff_single_plane_imaging_extractor.file_path) - with ScanImageTiffReader(file_path) as io: - assert dtype == io.data().dtype + assert dtype == expected_properties["dtype"] def test_check_frame_inputs_valid(scan_image_tiff_single_plane_imaging_extractor): scan_image_tiff_single_plane_imaging_extractor.check_frame_inputs(frame=0) -def test_check_frame_inputs_invalid(scan_image_tiff_single_plane_imaging_extractor): - num_frames = scan_image_tiff_single_plane_imaging_extractor.get_num_frames() +def test_check_frame_inputs_invalid(scan_image_tiff_single_plane_imaging_extractor, expected_properties): + num_frames = expected_properties["num_frames"] with pytest.raises(ValueError): scan_image_tiff_single_plane_imaging_extractor.check_frame_inputs(frame=num_frames + 1) -@pytest.mark.parametrize("frame", (0, 10, 31)) -def test_frame_to_raw_index_adesnik( - scan_image_tiff_single_plane_imaging_extractor_adesnik, num_channels_adesnik, num_planes_adesnik, frame +@pytest.mark.parametrize("frame", (0, 3, 5)) +def test_frame_to_raw_index( + scan_image_tiff_single_plane_imaging_extractor, + frame, + expected_properties, ): - raw_index = scan_image_tiff_single_plane_imaging_extractor_adesnik.frame_to_raw_index(frame=frame) - plane = scan_image_tiff_single_plane_imaging_extractor_adesnik.plane - channel = scan_image_tiff_single_plane_imaging_extractor_adesnik.channel - assert raw_index == (frame * num_planes_adesnik * num_channels_adesnik) + (plane * num_channels_adesnik) + channel - - -@pytest.mark.parametrize("file_path", file_paths) -def test_extract_extra_metadata(file_path): - metadata = extract_extra_metadata(file_path) - io = ScanImageTiffReader(str(file_path)) - extra_metadata = {} - for metadata_string in (io.description(iframe=0), io.metadata()): - metadata_dict = { - x.split("=")[0].strip(): x.split("=")[1].strip() - for x in metadata_string.replace("\n", "\r").split("\r") - if "=" in x - } - extra_metadata = dict(**extra_metadata, **metadata_dict) - assert metadata == extra_metadata - - -@pytest.mark.parametrize("file_path", file_paths) -def test_parse_metadata(file_path): - metadata = extract_extra_metadata(file_path) - parsed_metadata = parse_metadata(metadata) - sampling_frequency = float(metadata["SI.hRoiManager.scanVolumeRate"]) - num_channels = len(metadata["SI.hChannels.channelsActive"].split(";")) - num_planes = int(metadata["SI.hStackManager.numSlices"]) - frames_per_slice = int(metadata["SI.hStackManager.framesPerSlice"]) - channel_names = metadata["SI.hChannels.channelName"].split("'")[1::2][:num_channels] - assert parsed_metadata == dict( - sampling_frequency=sampling_frequency, - num_channels=num_channels, - num_planes=num_planes, - frames_per_slice=frames_per_slice, - channel_names=channel_names, + raw_index = scan_image_tiff_single_plane_imaging_extractor.frame_to_raw_index(frame=frame) + plane = scan_image_tiff_single_plane_imaging_extractor.plane + channel = scan_image_tiff_single_plane_imaging_extractor.channel + num_planes = expected_properties["num_planes"] + num_channels = expected_properties["num_channels"] + frames_per_slice = expected_properties["frames_per_slice"] + cycle = frame // frames_per_slice + frame_in_cycle = frame % frames_per_slice + expected_index = ( + cycle * num_planes * num_channels * frames_per_slice + + plane * num_channels * frames_per_slice + + num_channels * frame_in_cycle + + channel ) + assert raw_index == expected_index -def test_parse_metadata_v3_8(): - file_path = scan_image_path / "sample_scanimage_version_3_8.tiff" - metadata = extract_extra_metadata(file_path) - parsed_metadata = parse_metadata_v3_8(metadata) - sampling_frequency = float(metadata["state.acq.frameRate"]) - num_channels = int(metadata["state.acq.numberOfChannelsSave"]) - num_planes = int(metadata["state.acq.numberOfZSlices"]) - assert parsed_metadata == dict( - sampling_frequency=sampling_frequency, - num_channels=num_channels, - num_planes=num_planes, - ) - - -@pytest.mark.parametrize("file_path", file_paths) def test_ScanImageTiffMultiPlaneImagingExtractor__init__(file_path): extractor = ScanImageTiffMultiPlaneImagingExtractor(file_path=file_path) assert extractor.file_path == file_path -@pytest.mark.parametrize("channel_name, plane_name", [("Invalid Channel", "0"), ("Channel 1", "Invalid Plane")]) -def test_ScanImageTiffSinglePlaneImagingExtractor__init__invalid(channel_name, plane_name): - with pytest.raises(ValueError): - ScanImageTiffSinglePlaneImagingExtractor( - file_path=file_paths[0], channel_name=channel_name, plane_name=plane_name - ) - - -def test_ScanImageTiffMultiPlaneImagingExtractor__init__invalid(): +def test_ScanImageTiffMultiPlaneImagingExtractor__init__invalid(file_path): with pytest.raises(ValueError): - ScanImageTiffMultiPlaneImagingExtractor(file_path=file_paths[0], channel_name="Invalid Channel") + ScanImageTiffMultiPlaneImagingExtractor(file_path=file_path, channel_name="Invalid Channel") From 72f21c46d0508af0c51cc23155c2daf68cb15be9 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Mon, 25 Sep 2023 14:29:46 -0700 Subject: [PATCH 39/47] fixed bug in get_video for multiple frames per slice --- .../scanimagetiffimagingextractor.py | 53 ++++++++++++------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index 66fe065d..72927113 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -523,33 +523,48 @@ def get_video(self, start_frame=None, end_frame=None) -> np.ndarray: start_frame = 0 if end_frame is None: end_frame = self._num_frames - self.check_frame_inputs(end_frame - 1) + end_frame_inclusive = end_frame - 1 + self.check_frame_inputs(end_frame_inclusive) self.check_frame_inputs(start_frame) - ScanImageTiffReader = _get_scanimage_reader() start_cycle = start_frame // self._frames_per_slice end_cycle = end_frame // self._frames_per_slice - end_cycle_ceiling = np.ceil(end_frame / self._frames_per_slice).astype(int) - raw_start = self.frame_to_raw_index(start_cycle * self._frames_per_slice) - raw_end = self.frame_to_raw_index(end_cycle_ceiling * self._frames_per_slice) - print(f"{raw_start = }") - print(f"{raw_end = }") - with ScanImageTiffReader(filename=str(self.file_path)) as io: - raw_video = io.data(beg=raw_start, end=raw_end) - - print(f"{start_cycle = }") - print(f"{end_cycle = }") + # raw_start = self.frame_to_raw_index(start_cycle * self._frames_per_slice) + raw_start = start_cycle * self._num_raw_per_cycle + raw_end = self.frame_to_raw_index(end_frame_inclusive) + raw_end += 1 # ScanImageTiffReader is exclusive of the end index start_frame_in_cycle = start_frame % self._frames_per_slice end_frame_in_cycle = end_frame % self._frames_per_slice - print(f"{start_frame_in_cycle = }") - print(f"{end_frame_in_cycle = }") + start_left_in_cycle = (self._frames_per_slice - start_frame_in_cycle) % self._frames_per_slice + end_left_in_cycle = (self._frames_per_slice - end_frame_in_cycle) % self._frames_per_slice + print(f"raw_start: {raw_start}") + print(f"raw_end: {raw_end}") + print(f"start_frame_in_cycle: {start_frame_in_cycle}") + print(f"end_frame_in_cycle: {end_frame_in_cycle}") + ScanImageTiffReader = _get_scanimage_reader() + with ScanImageTiffReader(filename=str(self.file_path)) as io: + raw_video = io.data(beg=raw_start, end=raw_end) + num_cycles = end_cycle - np.ceil(start_frame / self._frames_per_slice).astype("int") index = [] - for i in range(end_cycle - start_cycle): + for j in range(start_left_in_cycle): # Add remaining frames from first (incomplete) cycle + idx = self.channel + (j + start_frame_in_cycle) * self._num_channels + self.plane * self._num_raw_per_plane + index.append(idx) + for i in range(num_cycles): for j in range(self._frames_per_slice): - index.append(i * self._num_raw_per_cycle + ((j + start_frame_in_cycle) * self._num_channels)) - for j in range(end_frame_in_cycle): - index.append( - (end_cycle - start_cycle) * self._num_raw_per_cycle + ((j + start_frame_in_cycle) * self._num_channels) + idx = ( + self.channel + + j * self._num_channels + + self.plane * self._num_raw_per_plane + + (i + bool(start_left_in_cycle)) * self._num_raw_per_cycle + ) + index.append(idx) + for j in range(end_left_in_cycle): # Add remaining frames from last (incomplete) cycle + idx = ( + self.channel + + j * self._num_channels + + self.plane * self._num_raw_per_plane + + num_cycles * self._num_raw_per_cycle ) + index.append(idx) print(f"index: {index}") video = raw_video[index] return video From 663b819619235f9c4315b319876bfabbecacdb94 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Mon, 25 Sep 2023 14:41:56 -0700 Subject: [PATCH 40/47] optimized indexing to only i/o the necessary slice --- .../scanimagetiffimagingextractor.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index 72927113..cd387287 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -529,7 +529,8 @@ def get_video(self, start_frame=None, end_frame=None) -> np.ndarray: start_cycle = start_frame // self._frames_per_slice end_cycle = end_frame // self._frames_per_slice # raw_start = self.frame_to_raw_index(start_cycle * self._frames_per_slice) - raw_start = start_cycle * self._num_raw_per_cycle + # raw_start = start_cycle * self._num_raw_per_cycle + raw_start = self.frame_to_raw_index(start_frame) raw_end = self.frame_to_raw_index(end_frame_inclusive) raw_end += 1 # ScanImageTiffReader is exclusive of the end index start_frame_in_cycle = start_frame % self._frames_per_slice @@ -546,24 +547,16 @@ def get_video(self, start_frame=None, end_frame=None) -> np.ndarray: num_cycles = end_cycle - np.ceil(start_frame / self._frames_per_slice).astype("int") index = [] for j in range(start_left_in_cycle): # Add remaining frames from first (incomplete) cycle - idx = self.channel + (j + start_frame_in_cycle) * self._num_channels + self.plane * self._num_raw_per_plane + idx = j * self._num_channels index.append(idx) for i in range(num_cycles): for j in range(self._frames_per_slice): - idx = ( - self.channel - + j * self._num_channels - + self.plane * self._num_raw_per_plane + index.append( + (j - start_frame_in_cycle) * self._num_channels + (i + bool(start_left_in_cycle)) * self._num_raw_per_cycle ) - index.append(idx) for j in range(end_left_in_cycle): # Add remaining frames from last (incomplete) cycle - idx = ( - self.channel - + j * self._num_channels - + self.plane * self._num_raw_per_plane - + num_cycles * self._num_raw_per_cycle - ) + idx = (j - start_frame_in_cycle) * self._num_channels + num_cycles * self._num_raw_per_cycle index.append(idx) print(f"index: {index}") video = raw_video[index] From 36f93945417a2a39f42ea9f087ad77bc5244ca40 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Mon, 25 Sep 2023 14:50:55 -0700 Subject: [PATCH 41/47] clean up code for readability --- .../scanimagetiffimagingextractor.py | 31 +++++++------------ 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index cd387287..98738c4c 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -526,29 +526,24 @@ def get_video(self, start_frame=None, end_frame=None) -> np.ndarray: end_frame_inclusive = end_frame - 1 self.check_frame_inputs(end_frame_inclusive) self.check_frame_inputs(start_frame) - start_cycle = start_frame // self._frames_per_slice - end_cycle = end_frame // self._frames_per_slice - # raw_start = self.frame_to_raw_index(start_cycle * self._frames_per_slice) - # raw_start = start_cycle * self._num_raw_per_cycle raw_start = self.frame_to_raw_index(start_frame) - raw_end = self.frame_to_raw_index(end_frame_inclusive) - raw_end += 1 # ScanImageTiffReader is exclusive of the end index + raw_end_inclusive = self.frame_to_raw_index(end_frame_inclusive) # frame_to_raw_index requires inclusive frame + raw_end = raw_end_inclusive + 1 + + ScanImageTiffReader = _get_scanimage_reader() + with ScanImageTiffReader(filename=str(self.file_path)) as io: + raw_video = io.data(beg=raw_start, end=raw_end) + + start_cycle = np.ceil(start_frame / self._frames_per_slice).astype("int") + end_cycle = end_frame // self._frames_per_slice + num_cycles = end_cycle - start_cycle start_frame_in_cycle = start_frame % self._frames_per_slice end_frame_in_cycle = end_frame % self._frames_per_slice start_left_in_cycle = (self._frames_per_slice - start_frame_in_cycle) % self._frames_per_slice end_left_in_cycle = (self._frames_per_slice - end_frame_in_cycle) % self._frames_per_slice - print(f"raw_start: {raw_start}") - print(f"raw_end: {raw_end}") - print(f"start_frame_in_cycle: {start_frame_in_cycle}") - print(f"end_frame_in_cycle: {end_frame_in_cycle}") - ScanImageTiffReader = _get_scanimage_reader() - with ScanImageTiffReader(filename=str(self.file_path)) as io: - raw_video = io.data(beg=raw_start, end=raw_end) - num_cycles = end_cycle - np.ceil(start_frame / self._frames_per_slice).astype("int") index = [] for j in range(start_left_in_cycle): # Add remaining frames from first (incomplete) cycle - idx = j * self._num_channels - index.append(idx) + index.append(j * self._num_channels) for i in range(num_cycles): for j in range(self._frames_per_slice): index.append( @@ -556,9 +551,7 @@ def get_video(self, start_frame=None, end_frame=None) -> np.ndarray: + (i + bool(start_left_in_cycle)) * self._num_raw_per_cycle ) for j in range(end_left_in_cycle): # Add remaining frames from last (incomplete) cycle - idx = (j - start_frame_in_cycle) * self._num_channels + num_cycles * self._num_raw_per_cycle - index.append(idx) - print(f"index: {index}") + index.append(j - start_frame_in_cycle) * self._num_channels + num_cycles * self._num_raw_per_cycle video = raw_video[index] return video From ac6a7c0d2c5d07b9f99b9390734b1d240dc4169e Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Mon, 25 Sep 2023 14:56:42 -0700 Subject: [PATCH 42/47] fixed typo in get_video --- .../tiffimagingextractors/scanimagetiffimagingextractor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index 98738c4c..520f6625 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -550,8 +550,8 @@ def get_video(self, start_frame=None, end_frame=None) -> np.ndarray: (j - start_frame_in_cycle) * self._num_channels + (i + bool(start_left_in_cycle)) * self._num_raw_per_cycle ) - for j in range(end_left_in_cycle): # Add remaining frames from last (incomplete) cycle - index.append(j - start_frame_in_cycle) * self._num_channels + num_cycles * self._num_raw_per_cycle + for j in range(end_left_in_cycle): # Add remaining frames from last (incomplete) cycle) + index.append((j - start_frame_in_cycle) * self._num_channels + num_cycles * self._num_raw_per_cycle) video = raw_video[index] return video From 3f7d0d3ea0a9b542af0e777019fd9a566ec025e3 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Mon, 25 Sep 2023 19:57:06 -0700 Subject: [PATCH 43/47] updated test filename --- tests/test_scanimagetiffimagingextractor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_scanimagetiffimagingextractor.py b/tests/test_scanimagetiffimagingextractor.py index 1f935e23..debd3249 100644 --- a/tests/test_scanimagetiffimagingextractor.py +++ b/tests/test_scanimagetiffimagingextractor.py @@ -17,7 +17,7 @@ @pytest.fixture(scope="module") def file_path(): - return OPHYS_DATA_PATH / "imaging_datasets" / "ScanImage" / "roi.tif" + return OPHYS_DATA_PATH / "imaging_datasets" / "ScanImage" / "scanimage_20220923_roi.tif" @pytest.fixture(scope="module") From cabfa84b0000472686d0332ebbb7cf0c8755863f Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Tue, 26 Sep 2023 10:38:01 -0700 Subject: [PATCH 44/47] added _times to account for variable sampling rates (multiple frames per slice) --- .../scanimagetiffimagingextractor.py | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index 520f6625..da271c69 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -169,6 +169,33 @@ def parse_metadata_v3_8(metadata): return metadata_parsed +def extract_timestamps_from_file(file_path): + """Extract the frame timestamps from a ScanImage TIFF file. + + Parameters + ---------- + file_path : PathType + Path to the TIFF file. + + Returns + ------- + timestamps : numpy.ndarray + Array of frame timestamps in seconds. + """ + ScanImageTiffReader = _get_scanimage_reader() + io = ScanImageTiffReader(str(file_path)) + num_frames = io.shape()[0] + timestamps = np.zeros(num_frames) + for iframe in range(num_frames): + description = io.description(iframe=iframe) + description_lines = description.split("\n") + for line in description_lines: + if "frameTimestamps_sec" in line: + timestamps[iframe] = float(line.split("=")[1].strip()) + break + return timestamps + + class MultiPlaneImagingExtractor(ImagingExtractor): """Class to combine multiple ImagingExtractor objects by depth plane.""" @@ -420,7 +447,6 @@ def __init__( plane_name : str Name of the plane for this extractor (default=None). """ - super().__init__() self.file_path = Path(file_path) self.metadata = extract_extra_metadata(file_path) parsed_metadata = parse_metadata(self.metadata) @@ -460,6 +486,9 @@ def __init__( "Extractor cannot handle 4D ScanImageTiff data. Please raise an issue to request this feature: " "https://github.com/catalystneuro/roiextractors/issues " ) + timestamps = extract_timestamps_from_file(file_path) + index = [self.frame_to_raw_index(iframe) for iframe in range(self._num_frames)] + self._times = timestamps[index] def get_frames(self, frame_idxs: ArrayType) -> np.ndarray: """Get specific video frames from indices (not necessarily continuous). From e6f4496a4d4c863a35445c0f29ca939d449124df Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Tue, 26 Sep 2023 10:43:03 -0700 Subject: [PATCH 45/47] Switched sampling_frequency to scanFrameRate instead of scanVolumeRate --- .../tiffimagingextractors/scanimagetiffimagingextractor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index da271c69..5cdb1144 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -121,7 +121,7 @@ def parse_metadata(metadata): SI.hChannels.channelName = "{'channel_name_1' 'channel_name_2' ... 'channel_name_M'}" where M is the number of channels (active or not). """ - sampling_frequency = float(metadata["SI.hRoiManager.scanVolumeRate"]) + sampling_frequency = float(metadata["SI.hRoiManager.scanFrameRate"]) num_planes = int(metadata["SI.hStackManager.numSlices"]) frames_per_slice = int(metadata["SI.hStackManager.framesPerSlice"]) active_channels = parse_matlab_vector(metadata["SI.hChannels.channelsActive"]) From 22aa9eca23910f417f005279a0abf8dfd7f58771 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Tue, 26 Sep 2023 11:48:46 -0700 Subject: [PATCH 46/47] Refactored stand-alone functions to separate scanimage_utils module --- .../scanimagetiff_utils.py | 182 +++++++++++++++++ .../scanimagetiffimagingextractor.py | 188 +----------------- tests/test_scanimagetiffimagingextractor.py | 11 +- 3 files changed, 189 insertions(+), 192 deletions(-) create mode 100644 src/roiextractors/extractors/tiffimagingextractors/scanimagetiff_utils.py diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiff_utils.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiff_utils.py new file mode 100644 index 00000000..ac3d8ef7 --- /dev/null +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiff_utils.py @@ -0,0 +1,182 @@ +import numpy as np + +from ...extraction_tools import PathType, get_package + + +def _get_scanimage_reader() -> type: + """Import the scanimage-tiff-reader package and return the ScanImageTiffReader class.""" + return get_package( + package_name="ScanImageTiffReader", installation_instructions="pip install scanimage-tiff-reader" + ).ScanImageTiffReader + + +def extract_extra_metadata( + file_path: PathType, +) -> dict: # TODO: Refactor neuroconv to reference this implementation to avoid duplication + """Extract metadata from a ScanImage TIFF file. + + Parameters + ---------- + file_path : PathType + Path to the TIFF file. + + Returns + ------- + extra_metadata: dict + Dictionary of metadata extracted from the TIFF file. + + Notes + ----- + Known to work on SI versions v3.8.0, v2019bR0, and v2022.0.0. + """ + ScanImageTiffReader = _get_scanimage_reader() + io = ScanImageTiffReader(str(file_path)) + extra_metadata = {} + for metadata_string in (io.description(iframe=0), io.metadata()): + metadata_dict = { + x.split("=")[0].strip(): x.split("=")[1].strip() + for x in metadata_string.replace("\n", "\r").split("\r") + if "=" in x + } + extra_metadata = dict(**extra_metadata, **metadata_dict) + return extra_metadata + + +def parse_matlab_vector(matlab_vector: str) -> list: + """Parse a MATLAB vector string into a list of integer values. + + Parameters + ---------- + matlab_vector : str + MATLAB vector string. + + Returns + ------- + vector: list of int + List of integer values. + + Raises + ------ + ValueError + If the MATLAB vector string cannot be parsed. + + Notes + ----- + MATLAB vector string is of the form "[1 2 3 ... N]" or "[1,2,3,...,N]" or "[1;2;3;...;N]". + There may or may not be whitespace between the values. Ex. "[1, 2, 3]" or "[1,2,3]". + """ + vector = matlab_vector.strip("[]") + if ";" in vector: + vector = vector.split(";") + elif "," in vector: + vector = vector.split(",") + elif " " in vector: + vector = vector.split(" ") + elif len(vector) == 1: + pass + else: + raise ValueError(f"Could not parse vector from {matlab_vector}.") + vector = [int(x.strip()) for x in vector if x != ""] + return vector + + +def parse_metadata(metadata: dict) -> dict: + """Parse metadata dictionary to extract relevant information and store it standard keys for ImagingExtractors. + + Currently supports + - sampling_frequency + - num_planes + - frames_per_slice + - channel_names + - num_channels + + Parameters + ---------- + metadata : dict + Dictionary of metadata extracted from the TIFF file. + + Returns + ------- + metadata_parsed: dict + Dictionary of parsed metadata. + + Notes + ----- + Known to work on SI versions v2019bR0 and v2022.0.0. + SI.hChannels.channelsActive = string of MATLAB-style vector with channel integers (see parse_matlab_vector). + SI.hChannels.channelName = "{'channel_name_1' 'channel_name_2' ... 'channel_name_M'}" + where M is the number of channels (active or not). + """ + sampling_frequency = float(metadata["SI.hRoiManager.scanFrameRate"]) + num_planes = int(metadata["SI.hStackManager.numSlices"]) + frames_per_slice = int(metadata["SI.hStackManager.framesPerSlice"]) + active_channels = parse_matlab_vector(metadata["SI.hChannels.channelsActive"]) + channel_indices = np.array(active_channels) - 1 # Account for MATLAB indexing + channel_names = np.array(metadata["SI.hChannels.channelName"].split("'")[1::2]) + channel_names = channel_names[channel_indices].tolist() + num_channels = len(channel_names) + metadata_parsed = dict( + sampling_frequency=sampling_frequency, + num_channels=num_channels, + num_planes=num_planes, + frames_per_slice=frames_per_slice, + channel_names=channel_names, + ) + return metadata_parsed + + +def parse_metadata_v3_8(metadata: dict) -> dict: + """Parse metadata dictionary to extract relevant information and store it standard keys for ImagingExtractors. + + Requires old version of metadata (v3.8). + Currently supports + - sampling frequency + - num_channels + - num_planes + + Parameters + ---------- + metadata : dict + Dictionary of metadata extracted from the TIFF file. + + Returns + ------- + metadata_parsed: dict + Dictionary of parsed metadata. + """ + sampling_frequency = float(metadata["state.acq.frameRate"]) + num_channels = int(metadata["state.acq.numberOfChannelsSave"]) + num_planes = int(metadata["state.acq.numberOfZSlices"]) + metadata_parsed = dict( + sampling_frequency=sampling_frequency, + num_channels=num_channels, + num_planes=num_planes, + ) + return metadata_parsed + + +def extract_timestamps_from_file(file_path: PathType) -> np.ndarray: + """Extract the frame timestamps from a ScanImage TIFF file. + + Parameters + ---------- + file_path : PathType + Path to the TIFF file. + + Returns + ------- + timestamps : numpy.ndarray + Array of frame timestamps in seconds. + """ + ScanImageTiffReader = _get_scanimage_reader() + io = ScanImageTiffReader(str(file_path)) + num_frames = io.shape()[0] + timestamps = np.zeros(num_frames) + for iframe in range(num_frames): + description = io.description(iframe=iframe) + description_lines = description.split("\n") + for line in description_lines: + if "frameTimestamps_sec" in line: + timestamps[iframe] = float(line.split("=")[1].strip()) + break + return timestamps diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py index 5cdb1144..0ad12b43 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiffimagingextractor.py @@ -9,191 +9,15 @@ from typing import Optional, Tuple, List, Iterable from warnings import warn import numpy as np -from pprint import pprint - -from roiextractors.extraction_tools import DtypeType from ...extraction_tools import PathType, FloatType, ArrayType, DtypeType, get_package from ...imagingextractor import ImagingExtractor - - -def _get_scanimage_reader() -> type: - """Import the scanimage-tiff-reader package and return the ScanImageTiffReader class.""" - return get_package( - package_name="ScanImageTiffReader", installation_instructions="pip install scanimage-tiff-reader" - ).ScanImageTiffReader - - -def extract_extra_metadata( - file_path, -) -> dict: # TODO: Refactor neuroconv to reference this implementation to avoid duplication - """Extract metadata from a ScanImage TIFF file. - - Parameters - ---------- - file_path : PathType - Path to the TIFF file. - - Returns - ------- - extra_metadata: dict - Dictionary of metadata extracted from the TIFF file. - - Notes - ----- - Known to work on SI versions v3.8.0, v2019bR0, and v2022.0.0. - """ - ScanImageTiffReader = _get_scanimage_reader() - io = ScanImageTiffReader(str(file_path)) - extra_metadata = {} - for metadata_string in (io.description(iframe=0), io.metadata()): - metadata_dict = { - x.split("=")[0].strip(): x.split("=")[1].strip() - for x in metadata_string.replace("\n", "\r").split("\r") - if "=" in x - } - extra_metadata = dict(**extra_metadata, **metadata_dict) - return extra_metadata - - -def parse_matlab_vector(matlab_vector: str) -> list: - """Parse a MATLAB vector string into a list of integer values. - - Parameters - ---------- - matlab_vector : str - MATLAB vector string. - - Returns - ------- - vector: list of int - List of integer values. - - Raises - ------ - ValueError - If the MATLAB vector string cannot be parsed. - - Notes - ----- - MATLAB vector string is of the form "[1 2 3 ... N]" or "[1,2,3,...,N]" or "[1;2;3;...;N]". - There may or may not be whitespace between the values. Ex. "[1, 2, 3]" or "[1,2,3]". - """ - vector = matlab_vector.strip("[]") - if ";" in vector: - vector = vector.split(";") - elif "," in vector: - vector = vector.split(",") - elif " " in vector: - vector = vector.split(" ") - elif len(vector) == 1: - pass - else: - raise ValueError(f"Could not parse vector from {matlab_vector}.") - vector = [int(x.strip()) for x in vector if x != ""] - return vector - - -def parse_metadata(metadata): - """Parse metadata dictionary to extract relevant information and store it standard keys for ImagingExtractors. - - Currently supports - - sampling_frequency - - num_planes - - frames_per_slice - - channel_names - - num_channels - - Parameters - ---------- - metadata : dict - Dictionary of metadata extracted from the TIFF file. - - Returns - ------- - metadata_parsed: dict - Dictionary of parsed metadata. - - Notes - ----- - Known to work on SI versions v2019bR0 and v2022.0.0. - SI.hChannels.channelsActive = string of MATLAB-style vector with channel integers (see parse_matlab_vector). - SI.hChannels.channelName = "{'channel_name_1' 'channel_name_2' ... 'channel_name_M'}" - where M is the number of channels (active or not). - """ - sampling_frequency = float(metadata["SI.hRoiManager.scanFrameRate"]) - num_planes = int(metadata["SI.hStackManager.numSlices"]) - frames_per_slice = int(metadata["SI.hStackManager.framesPerSlice"]) - active_channels = parse_matlab_vector(metadata["SI.hChannels.channelsActive"]) - channel_indices = np.array(active_channels) - 1 # Account for MATLAB indexing - channel_names = np.array(metadata["SI.hChannels.channelName"].split("'")[1::2]) - channel_names = channel_names[channel_indices].tolist() - num_channels = len(channel_names) - metadata_parsed = dict( - sampling_frequency=sampling_frequency, - num_channels=num_channels, - num_planes=num_planes, - frames_per_slice=frames_per_slice, - channel_names=channel_names, - ) - return metadata_parsed - - -def parse_metadata_v3_8(metadata): - """Parse metadata dictionary to extract relevant information and store it standard keys for ImagingExtractors. - - Requires old version of metadata (v3.8). - Currently supports - - sampling frequency - - num_channels - - num_planes - - Parameters - ---------- - metadata : dict - Dictionary of metadata extracted from the TIFF file. - - Returns - ------- - metadata_parsed: dict - Dictionary of parsed metadata. - """ - sampling_frequency = float(metadata["state.acq.frameRate"]) - num_channels = int(metadata["state.acq.numberOfChannelsSave"]) - num_planes = int(metadata["state.acq.numberOfZSlices"]) - metadata_parsed = dict( - sampling_frequency=sampling_frequency, - num_channels=num_channels, - num_planes=num_planes, - ) - return metadata_parsed - - -def extract_timestamps_from_file(file_path): - """Extract the frame timestamps from a ScanImage TIFF file. - - Parameters - ---------- - file_path : PathType - Path to the TIFF file. - - Returns - ------- - timestamps : numpy.ndarray - Array of frame timestamps in seconds. - """ - ScanImageTiffReader = _get_scanimage_reader() - io = ScanImageTiffReader(str(file_path)) - num_frames = io.shape()[0] - timestamps = np.zeros(num_frames) - for iframe in range(num_frames): - description = io.description(iframe=iframe) - description_lines = description.split("\n") - for line in description_lines: - if "frameTimestamps_sec" in line: - timestamps[iframe] = float(line.split("=")[1].strip()) - break - return timestamps +from .scanimagetiff_utils import ( + extract_extra_metadata, + parse_metadata, + extract_timestamps_from_file, + _get_scanimage_reader, +) class MultiPlaneImagingExtractor(ImagingExtractor): diff --git a/tests/test_scanimagetiffimagingextractor.py b/tests/test_scanimagetiffimagingextractor.py index debd3249..845fc443 100644 --- a/tests/test_scanimagetiffimagingextractor.py +++ b/tests/test_scanimagetiffimagingextractor.py @@ -1,16 +1,7 @@ import pytest -from pathlib import Path -from tempfile import mkdtemp -from shutil import rmtree, copy from numpy.testing import assert_array_equal - from ScanImageTiffReader import ScanImageTiffReader from roiextractors import ScanImageTiffSinglePlaneImagingExtractor, ScanImageTiffMultiPlaneImagingExtractor -from roiextractors.extractors.tiffimagingextractors.scanimagetiffimagingextractor import ( - extract_extra_metadata, - parse_metadata, - parse_metadata_v3_8, -) from .setup_paths import OPHYS_DATA_PATH @@ -23,7 +14,7 @@ def file_path(): @pytest.fixture(scope="module") def expected_properties(): return dict( - sampling_frequency=7.28119, + sampling_frequency=29.1248, num_channels=2, num_planes=2, frames_per_slice=2, From 5f3980e051d2eabd90dbada48c440513431cc8c0 Mon Sep 17 00:00:00 2001 From: pauladkisson Date: Tue, 26 Sep 2023 14:56:41 -0700 Subject: [PATCH 47/47] Added tests for scanimage utils --- .../scanimagetiff_utils.py | 15 +- tests/test_scanimage_utils.py | 128 ++++++++++++++++++ 2 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 tests/test_scanimage_utils.py diff --git a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiff_utils.py b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiff_utils.py index ac3d8ef7..49f1c981 100644 --- a/src/roiextractors/extractors/tiffimagingextractors/scanimagetiff_utils.py +++ b/src/roiextractors/extractors/tiffimagingextractors/scanimagetiff_utils.py @@ -27,7 +27,7 @@ def extract_extra_metadata( Notes ----- - Known to work on SI versions v3.8.0, v2019bR0, and v2022.0.0. + Known to work on SI versions v3.8.0, v2019bR0, v2022.0.0, and v2023.0.0 """ ScanImageTiffReader = _get_scanimage_reader() io = ScanImageTiffReader(str(file_path)) @@ -102,7 +102,7 @@ def parse_metadata(metadata: dict) -> dict: Notes ----- - Known to work on SI versions v2019bR0 and v2022.0.0. + Known to work on SI versions v2019bR0, v2022.0.0, and v2023.0.0. Fails on v3.8.0. SI.hChannels.channelsActive = string of MATLAB-style vector with channel integers (see parse_matlab_vector). SI.hChannels.channelName = "{'channel_name_1' 'channel_name_2' ... 'channel_name_M'}" where M is the number of channels (active or not). @@ -167,9 +167,19 @@ def extract_timestamps_from_file(file_path: PathType) -> np.ndarray: ------- timestamps : numpy.ndarray Array of frame timestamps in seconds. + + Raises + ------ + AssertionError + If the frame timestamps are not found in the TIFF file. + + Notes + ----- + Known to work on SI versions v2019bR0, v2022.0.0, and v2023.0.0. Fails on v3.8.0. """ ScanImageTiffReader = _get_scanimage_reader() io = ScanImageTiffReader(str(file_path)) + assert "frameTimestamps_sec" in io.description(iframe=0), "frameTimestamps_sec not found in TIFF file" num_frames = io.shape()[0] timestamps = np.zeros(num_frames) for iframe in range(num_frames): @@ -179,4 +189,5 @@ def extract_timestamps_from_file(file_path: PathType) -> np.ndarray: if "frameTimestamps_sec" in line: timestamps[iframe] = float(line.split("=")[1].strip()) break + return timestamps diff --git a/tests/test_scanimage_utils.py b/tests/test_scanimage_utils.py new file mode 100644 index 00000000..4b65b0cf --- /dev/null +++ b/tests/test_scanimage_utils.py @@ -0,0 +1,128 @@ +import pytest +from numpy.testing import assert_array_equal +from ScanImageTiffReader import ScanImageTiffReader +from roiextractors.extractors.tiffimagingextractors.scanimagetiff_utils import ( + _get_scanimage_reader, + extract_extra_metadata, + parse_matlab_vector, + parse_metadata, + parse_metadata_v3_8, + extract_timestamps_from_file, +) + +from .setup_paths import OPHYS_DATA_PATH + + +def test_get_scanimage_reader(): + ScanImageTiffReader = _get_scanimage_reader() + assert ScanImageTiffReader is not None + + +@pytest.mark.parametrize( + "filename, expected_key, expected_value", + [ + ("sample_scanimage_version_3_8.tiff", "state.software.version", "3.8"), + ("scanimage_20220801_single.tif", "SI.VERSION_MAJOR", "2022"), + ("scanimage_20220923_roi.tif", "SI.VERSION_MAJOR", "2023"), + ], +) +def test_extract_extra_metadata(filename, expected_key, expected_value): + file_path = OPHYS_DATA_PATH / "imaging_datasets" / "ScanImage" / filename + metadata = extract_extra_metadata(file_path) + assert metadata[expected_key] == expected_value + + +@pytest.mark.parametrize( + "matlab_vector, expected_vector", + [ + ("[1 2 3]", [1, 2, 3]), + ("[1,2,3]", [1, 2, 3]), + ("[1, 2, 3]", [1, 2, 3]), + ("[1;2;3]", [1, 2, 3]), + ("[1; 2; 3]", [1, 2, 3]), + ], +) +def test_parse_matlab_vector(matlab_vector, expected_vector): + vector = parse_matlab_vector(matlab_vector) + assert vector == expected_vector + + +@pytest.mark.parametrize( + "filename, expected_metadata", + [ + ( + "scanimage_20220801_single.tif", + { + "sampling_frequency": 15.2379, + "num_channels": 1, + "num_planes": 20, + "frames_per_slice": 24, + "channel_names": ["Channel 1"], + }, + ), + ( + "scanimage_20220923_roi.tif", + { + "sampling_frequency": 29.1248, + "num_channels": 2, + "num_planes": 2, + "frames_per_slice": 2, + "channel_names": ["Channel 1", "Channel 4"], + }, + ), + ], +) +def test_parse_metadata(filename, expected_metadata): + file_path = OPHYS_DATA_PATH / "imaging_datasets" / "ScanImage" / filename + metadata = extract_extra_metadata(file_path) + metadata = parse_metadata(metadata) + assert metadata == expected_metadata + + +def test_parse_metadata_v3_8(): + file_path = OPHYS_DATA_PATH / "imaging_datasets" / "ScanImage" / "sample_scanimage_version_3_8.tiff" + metadata = extract_extra_metadata(file_path) + metadata = parse_metadata_v3_8(metadata) + expected_metadata = {"sampling_frequency": 3.90625, "num_channels": 1, "num_planes": 1} + assert metadata == expected_metadata + + +@pytest.mark.parametrize( + "filename, expected_timestamps", + [ + ("scanimage_20220801_single.tif", [0.45951611, 0.98468446, 1.50985974]), + ( + "scanimage_20220923_roi.tif", + [ + 0.0, + 0.0, + 0.03433645, + 0.03433645, + 1.04890375, + 1.04890375, + 1.08324025, + 1.08324025, + 2.12027815, + 2.12027815, + 2.15461465, + 2.15461465, + 2.7413649, + 2.7413649, + 2.7757014, + 2.7757014, + 3.23987545, + 3.23987545, + 3.27421195, + 3.27421195, + 3.844804, + 3.844804, + 3.87914055, + 3.87914055, + ], + ), + ], +) +def test_extract_timestamps_from_file(filename, expected_timestamps): + file_path = OPHYS_DATA_PATH / "imaging_datasets" / "ScanImage" / filename + timestamps = extract_timestamps_from_file(file_path) + assert_array_equal(timestamps, expected_timestamps)