From a8d515d142e3fd6ae7da384716ca6ef10cfeaec0 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Mon, 7 Apr 2025 17:45:47 +0200 Subject: [PATCH 01/17] feat: add TemporalSlicer preprocessor and its tests --- .../pipeline/pipeline_component_registry.py | 1 + junifer/preprocess/__init__.pyi | 2 + junifer/preprocess/_temporal_slicer.py | 140 ++++++++++++++++++ .../preprocess/tests/test_temporal_slicer.py | 51 +++++++ 4 files changed, 194 insertions(+) create mode 100644 junifer/preprocess/_temporal_slicer.py create mode 100644 junifer/preprocess/tests/test_temporal_slicer.py diff --git a/junifer/pipeline/pipeline_component_registry.py b/junifer/pipeline/pipeline_component_registry.py index b83c26696..55a8fa31a 100644 --- a/junifer/pipeline/pipeline_component_registry.py +++ b/junifer/pipeline/pipeline_component_registry.py @@ -75,6 +75,7 @@ class PipelineComponentRegistry(metaclass=Singleton): "Smoothing": "Smoothing", "SpaceWarper": "SpaceWarper", "fMRIPrepConfoundRemover": "fMRIPrepConfoundRemover", + "TemporalSlicer": "TemporalSlicer", }, "marker": { "ALFFParcels": "ALFFParcels", diff --git a/junifer/preprocess/__init__.pyi b/junifer/preprocess/__init__.pyi index a67705ef9..c62792917 100644 --- a/junifer/preprocess/__init__.pyi +++ b/junifer/preprocess/__init__.pyi @@ -3,9 +3,11 @@ __all__ = [ "fMRIPrepConfoundRemover", "SpaceWarper", "Smoothing", + "TemporalSlicer", ] from .base import BasePreprocessor from .confounds import fMRIPrepConfoundRemover from .warping import SpaceWarper from .smoothing import Smoothing +from ._temporal_slicer import TemporalSlicer diff --git a/junifer/preprocess/_temporal_slicer.py b/junifer/preprocess/_temporal_slicer.py new file mode 100644 index 000000000..c993545fc --- /dev/null +++ b/junifer/preprocess/_temporal_slicer.py @@ -0,0 +1,140 @@ +"""Provide class for temporal slicing.""" + +# Authors: Synchon Mandal +# License: AGPL + +from typing import Any, ClassVar, Optional + +import nibabel as nib +import nilearn.image as nimg + +from ..api.decorators import register_preprocessor +from ..pipeline import WorkDirManager +from ..typing import Dependencies +from ..utils import logger +from .base import BasePreprocessor + + +__all__ = ["TemporalSlicer"] + + +@register_preprocessor +class TemporalSlicer(BasePreprocessor): + """Class for temporal slicing. + + Parameters + ---------- + start : int + Starting time point, in second. + stop : int + Ending time point, in second. + t_r : float, optional + Repetition time, in second (sampling period). + If None, it will use t_r from nifti header (default None). + + """ + + _DEPENDENCIES: ClassVar[Dependencies] = {"nilearn"} + + def __init__( + self, + start: int, + stop: int, + t_r: Optional[float] = None, + ) -> None: + """Initialize the class.""" + self.start = start + self.stop = stop + self.t_r = t_r + super().__init__(on="BOLD", required_data_types=["BOLD"]) + + def get_valid_inputs(self) -> list[str]: + """Get valid data types for input. + + Returns + ------- + list of str + The list of data types that can be used as input for this + preprocessor. + + """ + return ["BOLD"] + + def get_output_type(self, input_type: str) -> str: + """Get output type. + + Parameters + ---------- + input_type : str + The data type input to the preprocessor. + + Returns + ------- + str + The data type output by the preprocessor. + + """ + # Does not add any new keys + return input_type + + def preprocess( + self, + input: dict[str, Any], + extra_input: Optional[dict[str, Any]] = None, + ) -> tuple[dict[str, Any], Optional[dict[str, dict[str, Any]]]]: + """Preprocess. + + Parameters + ---------- + input : dict + The input from the Junifer Data object. + extra_input : dict, optional + The other fields in the Junifer Data object. + + Returns + ------- + dict + The computed result as dictionary. + None + Extra "helper" data types as dictionary to add to the Junifer Data + object. + + """ + logger.debug("Temporal slicing") + + # Get BOLD data + bold_img = input["data"] + # Set t_r + t_r = self.t_r + if t_r is None: + logger.info("No `t_r` specified, using t_r from NIfTI header") + t_r = bold_img.header.get_zooms()[3] # type: ignore + logger.info( + f"Read t_r from NIfTI header: {t_r}", + ) + + # Create element-specific tempdir for storing generated data + element_tempdir = WorkDirManager().get_element_tempdir( + prefix="temporal_slicer" + ) + + # Slice image after converting slice range from seconds to indices + index = slice(int(self.start // t_r), int(self.stop // t_r)) + sliced_img = nimg.index_img(bold_img, index) + # Fix t_r as nilearn messes it up + sliced_img.header["pixdim"][4] = t_r + # Save sliced data + sliced_img_path = element_tempdir / "sliced_data.nii.gz" + nib.save(sliced_img, sliced_img_path) + + logger.debug("Updating `BOLD`") + input.update( + { + # Update path to sync with "data" + "path": sliced_img_path, + # Update data + "data": sliced_img, + } + ) + + return input, None diff --git a/junifer/preprocess/tests/test_temporal_slicer.py b/junifer/preprocess/tests/test_temporal_slicer.py new file mode 100644 index 000000000..ec857a9fc --- /dev/null +++ b/junifer/preprocess/tests/test_temporal_slicer.py @@ -0,0 +1,51 @@ +"""Provide tests for TemporalSlicer.""" + +# Authors: Synchon Mandal +# License: AGPL + +import pytest + +from junifer.datareader import DefaultDataReader +from junifer.preprocess import TemporalSlicer +from junifer.testing.datagrabbers import PartlyCloudyTestingDataGrabber + + +@pytest.mark.parametrize( + "start, stop, t_r, expected_dim", + ( + [0, 168, 2.0, 84], # t_r from doc is 2.0 + [0, 168, None, 168], # t_r from image is 1.0 + ), +) +def test_TemporalSlicer( + start: int, + stop: int, + t_r: float, + expected_dim: int, +) -> None: + """Test TemporalSlicer. + + Parameters + ---------- + start : int + The parametrized start. + stop : int + The parametrized stop. + t_r : float + The parametrized TR. + expected_dim : int + The parametrized expected time dimension size. + + """ + + with PartlyCloudyTestingDataGrabber() as dg: + # Read data + element_data = DefaultDataReader().fit_transform(dg["sub-01"]) + # Preprocess data + output = TemporalSlicer( + start=start, + stop=stop, # in seconds + t_r=t_r, # in seconds + ).fit_transform(element_data) + + assert output["BOLD"]["data"].shape[3] == expected_dim -- 2.52.0 From 950db50f2e7c5f7e3c58042d612200f0a233775d Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Mon, 7 Apr 2025 17:46:43 +0200 Subject: [PATCH 02/17] docs: add TemporalSlicer to builtin --- docs/builtin.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/builtin.rst b/docs/builtin.rst index 2c31cabef..e802ab47b 100644 --- a/docs/builtin.rst +++ b/docs/builtin.rst @@ -163,6 +163,10 @@ Available | ``fMRIPrep``-ed data - In Progress - :gh:`161` + * - ``TemporalSlicer`` + - Slice ``BOLD`` data temporally + - | Done + - :gh:`443` .. -- 2.52.0 From e7db6aa1b49918236ebcee7c318e4dbf9dd9af3a Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Mon, 7 Apr 2025 17:52:40 +0200 Subject: [PATCH 03/17] chore: add changelog 443.feature --- docs/changes/newsfragments/443.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/changes/newsfragments/443.feature diff --git a/docs/changes/newsfragments/443.feature b/docs/changes/newsfragments/443.feature new file mode 100644 index 000000000..3f567498e --- /dev/null +++ b/docs/changes/newsfragments/443.feature @@ -0,0 +1 @@ +Introduce :class:`.TemporalSlicer` preprocessor for temporally slicing BOLD data by `Synchon Mandal`_ -- 2.52.0 From 93a826759dd8f6c0ab1399eded6015a4ed0e14f0 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Mon, 7 Apr 2025 18:10:03 +0200 Subject: [PATCH 04/17] update: make TemporalSlicer start and stop floats --- junifer/preprocess/_temporal_slicer.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/junifer/preprocess/_temporal_slicer.py b/junifer/preprocess/_temporal_slicer.py index c993545fc..284ba60e0 100644 --- a/junifer/preprocess/_temporal_slicer.py +++ b/junifer/preprocess/_temporal_slicer.py @@ -24,9 +24,9 @@ class TemporalSlicer(BasePreprocessor): Parameters ---------- - start : int + start : float Starting time point, in second. - stop : int + stop : float Ending time point, in second. t_r : float, optional Repetition time, in second (sampling period). @@ -38,8 +38,8 @@ class TemporalSlicer(BasePreprocessor): def __init__( self, - start: int, - stop: int, + start: float, + stop: float, t_r: Optional[float] = None, ) -> None: """Initialize the class.""" -- 2.52.0 From 7ffb1d26641a9168b034de8edc764858297dafc0 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Tue, 8 Apr 2025 12:10:20 +0200 Subject: [PATCH 05/17] update: add support for negative indexing in TemporalSlicer stop --- junifer/preprocess/_temporal_slicer.py | 7 ++++++- junifer/preprocess/tests/test_temporal_slicer.py | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/junifer/preprocess/_temporal_slicer.py b/junifer/preprocess/_temporal_slicer.py index 284ba60e0..7dd095aa1 100644 --- a/junifer/preprocess/_temporal_slicer.py +++ b/junifer/preprocess/_temporal_slicer.py @@ -118,8 +118,13 @@ class TemporalSlicer(BasePreprocessor): prefix="temporal_slicer" ) + # Calculate stop index if going from end + if self.stop < 0: + stop = bold_img.shape[3] + 1 + self.stop + else: + stop = self.stop # Slice image after converting slice range from seconds to indices - index = slice(int(self.start // t_r), int(self.stop // t_r)) + index = slice(int(self.start // t_r), int(stop // t_r)) sliced_img = nimg.index_img(bold_img, index) # Fix t_r as nilearn messes it up sliced_img.header["pixdim"][4] = t_r diff --git a/junifer/preprocess/tests/test_temporal_slicer.py b/junifer/preprocess/tests/test_temporal_slicer.py index ec857a9fc..010d72c24 100644 --- a/junifer/preprocess/tests/test_temporal_slicer.py +++ b/junifer/preprocess/tests/test_temporal_slicer.py @@ -14,7 +14,13 @@ from junifer.testing.datagrabbers import PartlyCloudyTestingDataGrabber "start, stop, t_r, expected_dim", ( [0, 168, 2.0, 84], # t_r from doc is 2.0 + [0, 84, 2.0, 42], # first half + [0, -85, 2.0, 42], # first half from end + [84, -1, 2.0, 42], # second half [0, 168, None, 168], # t_r from image is 1.0 + [0, 84, None, 84], # first half + [0, -85, None, 84], # first half from end + [84, -1, None, 84], # second half ), ) def test_TemporalSlicer( -- 2.52.0 From 396ab1d25422848a2e30cf0e3c3743b36c89280f Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Tue, 8 Apr 2025 14:59:17 +0200 Subject: [PATCH 06/17] update: allow TemporalSlicer end to be None --- junifer/preprocess/_temporal_slicer.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/junifer/preprocess/_temporal_slicer.py b/junifer/preprocess/_temporal_slicer.py index 7dd095aa1..c40e862a2 100644 --- a/junifer/preprocess/_temporal_slicer.py +++ b/junifer/preprocess/_temporal_slicer.py @@ -3,7 +3,7 @@ # Authors: Synchon Mandal # License: AGPL -from typing import Any, ClassVar, Optional +from typing import Any, ClassVar, Optional, Union import nibabel as nib import nilearn.image as nimg @@ -26,8 +26,10 @@ class TemporalSlicer(BasePreprocessor): ---------- start : float Starting time point, in second. - stop : float - Ending time point, in second. + stop : float or None + Ending time point, in second. If None, stops at the last time point. + Can also do negative indexing and has the same meaning as standard + Python slicing except it represents time points. t_r : float, optional Repetition time, in second (sampling period). If None, it will use t_r from nifti header (default None). @@ -39,7 +41,7 @@ class TemporalSlicer(BasePreprocessor): def __init__( self, start: float, - stop: float, + stop: Union[float, None], t_r: Optional[float] = None, ) -> None: """Initialize the class.""" @@ -118,11 +120,15 @@ class TemporalSlicer(BasePreprocessor): prefix="temporal_slicer" ) - # Calculate stop index if going from end - if self.stop < 0: - stop = bold_img.shape[3] + 1 + self.stop + # Check stop + if self.stop is None: + stop = bold_img.shape[3] else: - stop = self.stop + # Calculate stop index if going from end + if self.stop < 0: + stop = bold_img.shape[3] + 1 + self.stop + else: + stop = self.stop # Slice image after converting slice range from seconds to indices index = slice(int(self.start // t_r), int(stop // t_r)) sliced_img = nimg.index_img(bold_img, index) -- 2.52.0 From be5160b1b83658a2c1da362ae9a70f7b970295f2 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Tue, 8 Apr 2025 15:01:39 +0200 Subject: [PATCH 07/17] update: raise error if TemporalSlicer will not be used --- junifer/preprocess/_temporal_slicer.py | 24 ++++++- .../preprocess/tests/test_temporal_slicer.py | 65 ++++++++++++++----- 2 files changed, 73 insertions(+), 16 deletions(-) diff --git a/junifer/preprocess/_temporal_slicer.py b/junifer/preprocess/_temporal_slicer.py index c40e862a2..7a6047a40 100644 --- a/junifer/preprocess/_temporal_slicer.py +++ b/junifer/preprocess/_temporal_slicer.py @@ -11,7 +11,7 @@ import nilearn.image as nimg from ..api.decorators import register_preprocessor from ..pipeline import WorkDirManager from ..typing import Dependencies -from ..utils import logger +from ..utils import logger, raise_error from .base import BasePreprocessor @@ -101,11 +101,33 @@ class TemporalSlicer(BasePreprocessor): Extra "helper" data types as dictionary to add to the Junifer Data object. + Raises + ------ + RuntimeError + If no time slicing will be performed. + """ logger.debug("Temporal slicing") # Get BOLD data bold_img = input["data"] + + # Check if slicing is not required + if self.start == 0: + if ( + self.stop is None + or self.stop == -1 + or self.stop == bold_img.shape[3] + ): + raise_error( + "No temporal slicing will be performed as " + f"`start` = {self.start} and " + f"`stop` = {self.stop}, hence you " + "should remove the TemporalSlicer from the preprocess " + "step.", + klass=RuntimeError, + ) + # Set t_r t_r = self.t_r if t_r is None: diff --git a/junifer/preprocess/tests/test_temporal_slicer.py b/junifer/preprocess/tests/test_temporal_slicer.py index 010d72c24..b4ce876d2 100644 --- a/junifer/preprocess/tests/test_temporal_slicer.py +++ b/junifer/preprocess/tests/test_temporal_slicer.py @@ -3,6 +3,8 @@ # Authors: Synchon Mandal # License: AGPL +from contextlib import AbstractContextManager, nullcontext + import pytest from junifer.datareader import DefaultDataReader @@ -11,16 +13,45 @@ from junifer.testing.datagrabbers import PartlyCloudyTestingDataGrabber @pytest.mark.parametrize( - "start, stop, t_r, expected_dim", + "start, stop, t_r, expected_dim, expect", ( - [0, 168, 2.0, 84], # t_r from doc is 2.0 - [0, 84, 2.0, 42], # first half - [0, -85, 2.0, 42], # first half from end - [84, -1, 2.0, 42], # second half - [0, 168, None, 168], # t_r from image is 1.0 - [0, 84, None, 84], # first half - [0, -85, None, 84], # first half from end - [84, -1, None, 84], # second half + [ + 0, + 168, + 2.0, + 84, + pytest.raises(RuntimeError, match="No temporal slicing"), + ], # t_r from doc is 2.0 + [0, 167, 2.0, 83, nullcontext()], # t_r from doc is 2.0 + [ + 0, + None, + 2.0, + 84, + pytest.raises(RuntimeError, match="No temporal slicing"), + ], # total with no end + [2, None, 2.0, 83, nullcontext()], # total with no end + [0, 84, 2.0, 42, nullcontext()], # first half + [0, -85, 2.0, 42, nullcontext()], # first half from end + [84, -1, 2.0, 42, nullcontext()], # second half + [ + 0, + 168, + None, + 168, + pytest.raises(RuntimeError, match="No temporal slicing"), + ], # t_r from image is 1.0 + [0, 167, None, 167, nullcontext()], # t_r from image is 1.0 + [ + 0, + None, + None, + 168, + pytest.raises(RuntimeError, match="No temporal slicing"), + ], # total with no end + [0, 84, None, 84, nullcontext()], # first half + [0, -85, None, 84, nullcontext()], # first half from end + [84, -1, None, 84, nullcontext()], # second half ), ) def test_TemporalSlicer( @@ -28,6 +59,7 @@ def test_TemporalSlicer( stop: int, t_r: float, expected_dim: int, + expect: AbstractContextManager, ) -> None: """Test TemporalSlicer. @@ -41,6 +73,8 @@ def test_TemporalSlicer( The parametrized TR. expected_dim : int The parametrized expected time dimension size. + expect : typing.ContextManager + The parametrized ContextManager object. """ @@ -48,10 +82,11 @@ def test_TemporalSlicer( # Read data element_data = DefaultDataReader().fit_transform(dg["sub-01"]) # Preprocess data - output = TemporalSlicer( - start=start, - stop=stop, # in seconds - t_r=t_r, # in seconds - ).fit_transform(element_data) + with expect: + output = TemporalSlicer( + start=start, + stop=stop, # in seconds + t_r=t_r, # in seconds + ).fit_transform(element_data) - assert output["BOLD"]["data"].shape[3] == expected_dim + assert output["BOLD"]["data"].shape[3] == expected_dim -- 2.52.0 From 8debc27f3c00c8d4dea395c8e7d224f1b83a97b2 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Tue, 8 Apr 2025 16:09:56 +0200 Subject: [PATCH 08/17] update: add duration to TemporalSlicer --- junifer/preprocess/_temporal_slicer.py | 23 ++++++- .../preprocess/tests/test_temporal_slicer.py | 69 +++++++++++++------ 2 files changed, 68 insertions(+), 24 deletions(-) diff --git a/junifer/preprocess/_temporal_slicer.py b/junifer/preprocess/_temporal_slicer.py index 7a6047a40..0ae532d3e 100644 --- a/junifer/preprocess/_temporal_slicer.py +++ b/junifer/preprocess/_temporal_slicer.py @@ -30,6 +30,9 @@ class TemporalSlicer(BasePreprocessor): Ending time point, in second. If None, stops at the last time point. Can also do negative indexing and has the same meaning as standard Python slicing except it represents time points. + duration : float or None, optional + Time duration to add to ``start``, in second. If None, ``stop`` is + respected, else error is raised. t_r : float, optional Repetition time, in second (sampling period). If None, it will use t_r from nifti header (default None). @@ -42,11 +45,13 @@ class TemporalSlicer(BasePreprocessor): self, start: float, stop: Union[float, None], + duration: Union[float, None] = None, t_r: Optional[float] = None, ) -> None: """Initialize the class.""" self.start = start self.stop = stop + self.duration = duration self.t_r = t_r super().__init__(on="BOLD", required_data_types=["BOLD"]) @@ -104,7 +109,8 @@ class TemporalSlicer(BasePreprocessor): Raises ------ RuntimeError - If no time slicing will be performed. + If no time slicing will be performed or + if ``stop`` is not None when ``duration`` is provided. """ logger.debug("Temporal slicing") @@ -128,6 +134,14 @@ class TemporalSlicer(BasePreprocessor): klass=RuntimeError, ) + # Sanity check for stop and duration combination + if self.duration is not None and self.stop is not None: + raise_error( + "`stop` should be None if `duration` is not None. " + "Set `stop` = None for TemporalSlicer to continue.", + klass=RuntimeError, + ) + # Set t_r t_r = self.t_r if t_r is None: @@ -142,9 +156,12 @@ class TemporalSlicer(BasePreprocessor): prefix="temporal_slicer" ) - # Check stop + # Check stop; duration is None if self.stop is None: - stop = bold_img.shape[3] + if self.duration is not None: + stop = self.start + self.duration + else: + stop = bold_img.shape[3] else: # Calculate stop index if going from end if self.stop < 0: diff --git a/junifer/preprocess/tests/test_temporal_slicer.py b/junifer/preprocess/tests/test_temporal_slicer.py index b4ce876d2..5b03806a4 100644 --- a/junifer/preprocess/tests/test_temporal_slicer.py +++ b/junifer/preprocess/tests/test_temporal_slicer.py @@ -4,6 +4,7 @@ # License: AGPL from contextlib import AbstractContextManager, nullcontext +from typing import Union import pytest @@ -13,50 +14,73 @@ from junifer.testing.datagrabbers import PartlyCloudyTestingDataGrabber @pytest.mark.parametrize( - "start, stop, t_r, expected_dim, expect", + "start, stop, duration, t_r, expected_dim, expect", ( [ - 0, - 168, + 0.0, + 168.0, + None, 2.0, 84, pytest.raises(RuntimeError, match="No temporal slicing"), ], # t_r from doc is 2.0 - [0, 167, 2.0, 83, nullcontext()], # t_r from doc is 2.0 + [0.0, 167.0, None, 2.0, 83, nullcontext()], # t_r from doc is 2.0 [ - 0, + 0.0, + None, None, 2.0, 84, pytest.raises(RuntimeError, match="No temporal slicing"), ], # total with no end - [2, None, 2.0, 83, nullcontext()], # total with no end - [0, 84, 2.0, 42, nullcontext()], # first half - [0, -85, 2.0, 42, nullcontext()], # first half from end - [84, -1, 2.0, 42, nullcontext()], # second half + [2.0, None, None, 2.0, 83, nullcontext()], # total with no end + [0.0, 84.0, None, 2.0, 42, nullcontext()], # first half + [0.0, -85.0, None, 2.0, 42, nullcontext()], # first half from end + [84.0, -1.0, None, 2.0, 42, nullcontext()], # second half [ - 0, - 168, + 33.0, + -33.0, + 33.0, + 2.0, + 42, + pytest.raises(RuntimeError, match="`stop` should be None"), + ], + [10.0, None, 30.0, 2.0, 15, nullcontext()], + [ + 0.0, + 168.0, + None, None, 168, pytest.raises(RuntimeError, match="No temporal slicing"), ], # t_r from image is 1.0 - [0, 167, None, 167, nullcontext()], # t_r from image is 1.0 + [0.0, 167.0, None, None, 167, nullcontext()], # t_r from image is 1.0 [ - 0, + 0.0, + None, None, None, 168, pytest.raises(RuntimeError, match="No temporal slicing"), ], # total with no end - [0, 84, None, 84, nullcontext()], # first half - [0, -85, None, 84, nullcontext()], # first half from end - [84, -1, None, 84, nullcontext()], # second half + [0.0, 84.0, None, None, 84, nullcontext()], # first half + [0.0, -85.0, None, None, 84, nullcontext()], # first half from end + [84.0, -1.0, None, None, 84, nullcontext()], # second half + [ + 33.0, + -33.0, + 33.0, + None, + 84, + pytest.raises(RuntimeError, match="`stop` should be None"), + ], + [10.0, None, 30.0, None, 30, nullcontext()], ), ) def test_TemporalSlicer( - start: int, - stop: int, + start: float, + stop: Union[float, None], + duration: float, t_r: float, expected_dim: int, expect: AbstractContextManager, @@ -65,10 +89,12 @@ def test_TemporalSlicer( Parameters ---------- - start : int + start : float The parametrized start. - stop : int + stop : float The parametrized stop. + duration : float + The parametrized duration. t_r : float The parametrized TR. expected_dim : int @@ -84,8 +110,9 @@ def test_TemporalSlicer( # Preprocess data with expect: output = TemporalSlicer( - start=start, + start=start, # in seconds stop=stop, # in seconds + duration=duration, # in seconds t_r=t_r, # in seconds ).fit_transform(element_data) -- 2.52.0 From 327fba34ffe023f293cf760c856ed2db6edba637 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Fri, 11 Apr 2025 13:00:49 +0200 Subject: [PATCH 09/17] update: slice confounds for TemporalSlicer --- junifer/preprocess/_temporal_slicer.py | 28 +++++++++++++++++++ .../preprocess/tests/test_temporal_slicer.py | 3 ++ 2 files changed, 31 insertions(+) diff --git a/junifer/preprocess/_temporal_slicer.py b/junifer/preprocess/_temporal_slicer.py index 0ae532d3e..dd104b7d5 100644 --- a/junifer/preprocess/_temporal_slicer.py +++ b/junifer/preprocess/_temporal_slicer.py @@ -170,6 +170,8 @@ class TemporalSlicer(BasePreprocessor): stop = self.stop # Slice image after converting slice range from seconds to indices index = slice(int(self.start // t_r), int(stop // t_r)) + + # Slice image sliced_img = nimg.index_img(bold_img, index) # Fix t_r as nilearn messes it up sliced_img.header["pixdim"][4] = t_r @@ -187,4 +189,30 @@ class TemporalSlicer(BasePreprocessor): } ) + # Check for BOLD.confounds and update if found + if input.get("confounds") is not None: + # Slice confounds + sliced_confounds_df = input["confounds"]["data"].iloc[index, :] + # Save sliced confounds + sliced_confounds_path = ( + element_tempdir / "sliced_confounds_regressors.tsv" + ) + sliced_confounds_df.to_csv( + sliced_confounds_path, + sep="\t", + index=False, + ) + + logger.debug("Updating `BOLD.confounds`") + input.update( + { + "confounds": { + # Update path to sync with "data" + "path": sliced_confounds_path, + # Update data + "data": sliced_confounds_df, + } + } + ) + return input, None diff --git a/junifer/preprocess/tests/test_temporal_slicer.py b/junifer/preprocess/tests/test_temporal_slicer.py index 5b03806a4..d99ba9ecc 100644 --- a/junifer/preprocess/tests/test_temporal_slicer.py +++ b/junifer/preprocess/tests/test_temporal_slicer.py @@ -116,4 +116,7 @@ def test_TemporalSlicer( t_r=t_r, # in seconds ).fit_transform(element_data) + # Check image data dim assert output["BOLD"]["data"].shape[3] == expected_dim + # Check confounds dim + assert output["BOLD"]["confounds"]["data"].shape[0] == expected_dim -- 2.52.0 From dc9ba7e2d12af1028472363e999e7d7d657e67cd Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Mon, 14 Apr 2025 11:22:10 +0200 Subject: [PATCH 10/17] chore: update TemporalSlicer docstring --- junifer/preprocess/_temporal_slicer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/junifer/preprocess/_temporal_slicer.py b/junifer/preprocess/_temporal_slicer.py index dd104b7d5..203dd2e35 100644 --- a/junifer/preprocess/_temporal_slicer.py +++ b/junifer/preprocess/_temporal_slicer.py @@ -33,7 +33,7 @@ class TemporalSlicer(BasePreprocessor): duration : float or None, optional Time duration to add to ``start``, in second. If None, ``stop`` is respected, else error is raised. - t_r : float, optional + t_r : float or None, optional Repetition time, in second (sampling period). If None, it will use t_r from nifti header (default None). -- 2.52.0 From e84a9528a23185ea381bbfd8b50b066e5434b7f9 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Mon, 14 Apr 2025 11:22:42 +0200 Subject: [PATCH 11/17] update: restrict start to zero or positive for TemporalSlicer --- junifer/preprocess/_temporal_slicer.py | 10 +++++++++- junifer/preprocess/tests/test_temporal_slicer.py | 8 ++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/junifer/preprocess/_temporal_slicer.py b/junifer/preprocess/_temporal_slicer.py index 203dd2e35..9e982d8d1 100644 --- a/junifer/preprocess/_temporal_slicer.py +++ b/junifer/preprocess/_temporal_slicer.py @@ -37,6 +37,11 @@ class TemporalSlicer(BasePreprocessor): Repetition time, in second (sampling period). If None, it will use t_r from nifti header (default None). + Raises + ------ + ValueError + If ``start`` is negative. + """ _DEPENDENCIES: ClassVar[Dependencies] = {"nilearn"} @@ -49,7 +54,10 @@ class TemporalSlicer(BasePreprocessor): t_r: Optional[float] = None, ) -> None: """Initialize the class.""" - self.start = start + if start < 0: + raise_error("`start` cannot be negative") + else: + self.start = start self.stop = stop self.duration = duration self.t_r = t_r diff --git a/junifer/preprocess/tests/test_temporal_slicer.py b/junifer/preprocess/tests/test_temporal_slicer.py index d99ba9ecc..939b0ec98 100644 --- a/junifer/preprocess/tests/test_temporal_slicer.py +++ b/junifer/preprocess/tests/test_temporal_slicer.py @@ -75,6 +75,14 @@ from junifer.testing.datagrabbers import PartlyCloudyTestingDataGrabber pytest.raises(RuntimeError, match="`stop` should be None"), ], [10.0, None, 30.0, None, 30, nullcontext()], + [ + -1.0, + None, + None, + None, + 84, + pytest.raises(ValueError, match="`start` cannot be negative"), + ], ), ) def test_TemporalSlicer( -- 2.52.0 From e4927c097653f8adc99d98216a67ee340b5d26d4 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Mon, 14 Apr 2025 11:23:19 +0200 Subject: [PATCH 12/17] chore: update docstring for TemporalSlicer tests --- junifer/preprocess/tests/test_temporal_slicer.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/junifer/preprocess/tests/test_temporal_slicer.py b/junifer/preprocess/tests/test_temporal_slicer.py index 939b0ec98..bc53ff98a 100644 --- a/junifer/preprocess/tests/test_temporal_slicer.py +++ b/junifer/preprocess/tests/test_temporal_slicer.py @@ -88,8 +88,8 @@ from junifer.testing.datagrabbers import PartlyCloudyTestingDataGrabber def test_TemporalSlicer( start: float, stop: Union[float, None], - duration: float, - t_r: float, + duration: Union[float, None], + t_r: Union[float, None], expected_dim: int, expect: AbstractContextManager, ) -> None: @@ -99,11 +99,11 @@ def test_TemporalSlicer( ---------- start : float The parametrized start. - stop : float + stop : float or None The parametrized stop. - duration : float + duration : float or None The parametrized duration. - t_r : float + t_r : float or None The parametrized TR. expected_dim : int The parametrized expected time dimension size. -- 2.52.0 From 154d1bba24754f7611ad66907d86f58828310084 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Mon, 14 Apr 2025 11:23:58 +0200 Subject: [PATCH 13/17] chore: simplify variable flow in TemporalSlicer --- junifer/preprocess/_temporal_slicer.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/junifer/preprocess/_temporal_slicer.py b/junifer/preprocess/_temporal_slicer.py index 9e982d8d1..8409c130c 100644 --- a/junifer/preprocess/_temporal_slicer.py +++ b/junifer/preprocess/_temporal_slicer.py @@ -125,14 +125,11 @@ class TemporalSlicer(BasePreprocessor): # Get BOLD data bold_img = input["data"] + time_dim = bold_img.shape[3] # Check if slicing is not required if self.start == 0: - if ( - self.stop is None - or self.stop == -1 - or self.stop == bold_img.shape[3] - ): + if self.stop is None or self.stop == -1 or self.stop == time_dim: raise_error( "No temporal slicing will be performed as " f"`start` = {self.start} and " @@ -169,11 +166,11 @@ class TemporalSlicer(BasePreprocessor): if self.duration is not None: stop = self.start + self.duration else: - stop = bold_img.shape[3] + stop = time_dim else: # Calculate stop index if going from end if self.stop < 0: - stop = bold_img.shape[3] + 1 + self.stop + stop = time_dim + 1 + self.stop else: stop = self.stop # Slice image after converting slice range from seconds to indices -- 2.52.0 From 911815eba8de6e87892e24837d465dc3d6fba64f Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Mon, 14 Apr 2025 11:24:44 +0200 Subject: [PATCH 14/17] update: improve logging for TemporalSlicer --- junifer/preprocess/_temporal_slicer.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/junifer/preprocess/_temporal_slicer.py b/junifer/preprocess/_temporal_slicer.py index 8409c130c..04f8f686f 100644 --- a/junifer/preprocess/_temporal_slicer.py +++ b/junifer/preprocess/_temporal_slicer.py @@ -173,9 +173,15 @@ class TemporalSlicer(BasePreprocessor): stop = time_dim + 1 + self.stop else: stop = self.stop - # Slice image after converting slice range from seconds to indices + + # Convert slice range from seconds to indices index = slice(int(self.start // t_r), int(stop // t_r)) + logger.info( + "Computed slice range for TemporalSlicer: " + f"[{index.start},{index.stop}]" + ) + # Slice image sliced_img = nimg.index_img(bold_img, index) # Fix t_r as nilearn messes it up -- 2.52.0 From cc3860f6b9ea864dc38a404fce81cc8e334ceaca Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Mon, 14 Apr 2025 11:25:10 +0200 Subject: [PATCH 15/17] update: restrict stop from out of bounds for TemporalSlicer --- junifer/preprocess/_temporal_slicer.py | 11 ++++++++++- junifer/preprocess/tests/test_temporal_slicer.py | 8 ++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/junifer/preprocess/_temporal_slicer.py b/junifer/preprocess/_temporal_slicer.py index 04f8f686f..09f4feaf0 100644 --- a/junifer/preprocess/_temporal_slicer.py +++ b/junifer/preprocess/_temporal_slicer.py @@ -118,7 +118,8 @@ class TemporalSlicer(BasePreprocessor): ------ RuntimeError If no time slicing will be performed or - if ``stop`` is not None when ``duration`` is provided. + if ``stop`` is not None when ``duration`` is provided or + if calculated stop index is greater than allowed value. """ logger.debug("Temporal slicing") @@ -177,6 +178,14 @@ class TemporalSlicer(BasePreprocessor): # Convert slice range from seconds to indices index = slice(int(self.start // t_r), int(stop // t_r)) + # Check if stop index is out of bounds + if index.stop > time_dim: + raise_error( + f"Calculated stop index: {index.stop} is greater than " + f"allowed value: {time_dim}", + klass=IndexError, + ) + logger.info( "Computed slice range for TemporalSlicer: " f"[{index.start},{index.stop}]" diff --git a/junifer/preprocess/tests/test_temporal_slicer.py b/junifer/preprocess/tests/test_temporal_slicer.py index bc53ff98a..6fd8bb86a 100644 --- a/junifer/preprocess/tests/test_temporal_slicer.py +++ b/junifer/preprocess/tests/test_temporal_slicer.py @@ -83,6 +83,14 @@ from junifer.testing.datagrabbers import PartlyCloudyTestingDataGrabber 84, pytest.raises(ValueError, match="`start` cannot be negative"), ], + [ + 0.0, + 500.0, + None, + 2.0, + 42, + pytest.raises(IndexError, match="Calculated stop index:"), + ], ), ) def test_TemporalSlicer( -- 2.52.0 From bde0b40b80dec82779236ced7835bb338d797e20 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Wed, 7 May 2025 13:49:51 +0200 Subject: [PATCH 16/17] chore: update type hints and docstring for TemporalSlicer --- junifer/preprocess/_temporal_slicer.py | 8 ++++---- junifer/preprocess/tests/test_temporal_slicer.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/junifer/preprocess/_temporal_slicer.py b/junifer/preprocess/_temporal_slicer.py index 09f4feaf0..99708be79 100644 --- a/junifer/preprocess/_temporal_slicer.py +++ b/junifer/preprocess/_temporal_slicer.py @@ -3,7 +3,7 @@ # Authors: Synchon Mandal # License: AGPL -from typing import Any, ClassVar, Optional, Union +from typing import Any, ClassVar, Optional import nibabel as nib import nilearn.image as nimg @@ -32,7 +32,7 @@ class TemporalSlicer(BasePreprocessor): Python slicing except it represents time points. duration : float or None, optional Time duration to add to ``start``, in second. If None, ``stop`` is - respected, else error is raised. + respected, else error is raised (default None). t_r : float or None, optional Repetition time, in second (sampling period). If None, it will use t_r from nifti header (default None). @@ -49,8 +49,8 @@ class TemporalSlicer(BasePreprocessor): def __init__( self, start: float, - stop: Union[float, None], - duration: Union[float, None] = None, + stop: Optional[float], + duration: Optional[float] = None, t_r: Optional[float] = None, ) -> None: """Initialize the class.""" diff --git a/junifer/preprocess/tests/test_temporal_slicer.py b/junifer/preprocess/tests/test_temporal_slicer.py index 6fd8bb86a..ed2be4ef4 100644 --- a/junifer/preprocess/tests/test_temporal_slicer.py +++ b/junifer/preprocess/tests/test_temporal_slicer.py @@ -4,7 +4,7 @@ # License: AGPL from contextlib import AbstractContextManager, nullcontext -from typing import Union +from typing import Optional import pytest @@ -95,9 +95,9 @@ from junifer.testing.datagrabbers import PartlyCloudyTestingDataGrabber ) def test_TemporalSlicer( start: float, - stop: Union[float, None], - duration: Union[float, None], - t_r: Union[float, None], + stop: Optional[float], + duration: Optional[float], + t_r: Optional[float], expected_dim: int, expect: AbstractContextManager, ) -> None: -- 2.52.0 From 57acd732ed0b269406fdedd2bc228abe15743c70 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Wed, 7 May 2025 14:56:22 +0200 Subject: [PATCH 17/17] fix: correct data update for confounds in TemporalSlicer --- junifer/preprocess/_temporal_slicer.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/junifer/preprocess/_temporal_slicer.py b/junifer/preprocess/_temporal_slicer.py index 99708be79..bd82d3640 100644 --- a/junifer/preprocess/_temporal_slicer.py +++ b/junifer/preprocess/_temporal_slicer.py @@ -224,14 +224,12 @@ class TemporalSlicer(BasePreprocessor): ) logger.debug("Updating `BOLD.confounds`") - input.update( + input["confounds"].update( { - "confounds": { - # Update path to sync with "data" - "path": sliced_confounds_path, - # Update data - "data": sliced_confounds_df, - } + # Update path to sync with "data" + "path": sliced_confounds_path, + # Update data + "data": sliced_confounds_df, } ) -- 2.52.0