[ENH]: Refactor Preprocessors to sync data and their paths #408

Merged
synchon merged 5 commits from refactor/preprocessor into main 2024-12-03 10:26:06 +00:00
9 changed files with 290 additions and 118 deletions

View file

@ -0,0 +1 @@
Update Preprocessors to sync data their paths so that the results are idempotent by `Synchon Mandal`_

View file

@ -0,0 +1 @@
Allow masks to be warped, if found, in :class:`.SpaceWarper` by `Synchon Mandal`_

View file

@ -12,6 +12,7 @@ from typing import (
Union, Union,
) )
import nibabel as nib
import numpy as np import numpy as np
import pandas as pd import pandas as pd
from nilearn import image as nimg from nilearn import image as nimg
@ -19,6 +20,7 @@ from nilearn._utils.niimg_conversions import check_niimg_4d
from ...api.decorators import register_preprocessor from ...api.decorators import register_preprocessor
from ...data import get_data from ...data import get_data
from ...pipeline import WorkDirManager
from ...typing import Dependencies from ...typing import Dependencies
from ...utils import logger, raise_error from ...utils import logger, raise_error
from ..base import BasePreprocessor from ..base import BasePreprocessor
@ -539,9 +541,17 @@ class fMRIPrepConfoundRemover(BasePreprocessor):
logger.info( logger.info(
f"Read t_r from NIfTI header: {t_r}", f"Read t_r from NIfTI header: {t_r}",
) )
# Create element-specific tempdir for storing generated data
# and / or mask
element_tempdir = WorkDirManager().get_element_tempdir(
prefix="fmriprep_confound_remover"
)
# Set mask data # Set mask data
mask_img = None mask_img = None
if self.masks is not None: if self.masks is not None:
# Generate mask
logger.debug(f"Masking with {self.masks}") logger.debug(f"Masking with {self.masks}")
mask_img = get_data( mask_img = get_data(
kind="mask", kind="mask",
@ -549,13 +559,21 @@ class fMRIPrepConfoundRemover(BasePreprocessor):
target_data=input, target_data=input,
extra_input=extra_input, extra_input=extra_input,
) )
# Return the BOLD mask and link it to the BOLD data type dict; # Save generated mask for use later
generated_mask_img_path = element_tempdir / "generated_mask.nii.gz"
nib.save(mask_img, generated_mask_img_path)
# Save BOLD mask and link it to the BOLD data type dict;
# this allows to use "inherit" down the pipeline # this allows to use "inherit" down the pipeline
logger.debug("Setting `BOLD.mask`") logger.debug("Setting `BOLD.mask`")
input.update( input.update(
{ {
"mask": { "mask": {
# Update path to sync with "data"
"path": generated_mask_img_path,
# Update data
"data": mask_img, "data": mask_img,
# Should be in the same space as target data
"space": input["space"], "space": input["space"],
} }
} }
@ -566,7 +584,9 @@ class fMRIPrepConfoundRemover(BasePreprocessor):
logger.debug(f"\tstandardize: {self.standardize}") logger.debug(f"\tstandardize: {self.standardize}")
logger.debug(f"\tlow_pass: {self.low_pass}") logger.debug(f"\tlow_pass: {self.low_pass}")
logger.debug(f"\thigh_pass: {self.high_pass}") logger.debug(f"\thigh_pass: {self.high_pass}")
input["data"] = nimg.clean_img(
# Deconfound data
cleaned_img = nimg.clean_img(
imgs=bold_img, imgs=bold_img,
detrend=self.detrend, detrend=self.detrend,
standardize=self.standardize, standardize=self.standardize,
@ -576,5 +596,18 @@ class fMRIPrepConfoundRemover(BasePreprocessor):
t_r=t_r, t_r=t_r,
mask_img=mask_img, mask_img=mask_img,
) )
# Save deconfounded data
deconfounded_img_path = element_tempdir / "deconfounded_data.nii.gz"
nib.save(cleaned_img, deconfounded_img_path)
logger.debug("Updating `BOLD`")
input.update(
{
# Update path to sync with "data"
"path": deconfounded_img_path,
# Update data
"data": cleaned_img,
}
)
return input, None return input, None

View file

@ -4,7 +4,7 @@
# License: AGPL # License: AGPL
from typing import ( from typing import (
TYPE_CHECKING, Any,
ClassVar, ClassVar,
Union, Union,
) )
@ -16,10 +16,6 @@ from ...typing import Dependencies, ExternalDependencies
from ...utils import logger, run_ext_cmd from ...utils import logger, run_ext_cmd
if TYPE_CHECKING:
from nibabel.nifti1 import Nifti1Image
__all__ = ["AFNISmoothing"] __all__ = ["AFNISmoothing"]
@ -41,23 +37,23 @@ class AFNISmoothing:
def preprocess( def preprocess(
self, self,
data: "Nifti1Image", input: dict[str, Any],
fwhm: Union[int, float], fwhm: Union[int, float],
) -> "Nifti1Image": ) -> dict[str, Any]:
"""Preprocess using AFNI. """Preprocess using AFNI.
Parameters Parameters
---------- ----------
data : Niimg-like object input : dict
Image(s) to preprocess. A single input from the Junifer Data object in which to preprocess.
fwhm : int or float fwhm : int or float
Smooth until the value. AFNI estimates the smoothing and then Smooth until the value. AFNI estimates the smoothing and then
applies smoothing to reach ``fwhm``. applies smoothing to reach ``fwhm``.
Returns Returns
------- -------
Niimg-like object dict
The preprocessed image(s). The ``input`` dictionary with updated values.
Notes Notes
----- -----
@ -71,18 +67,18 @@ class AFNISmoothing:
""" """
logger.info("Smoothing using AFNI") logger.info("Smoothing using AFNI")
# Create component-scoped tempdir # Create element-scoped tempdir so that the output is
tempdir = WorkDirManager().get_tempdir(prefix="afni_smoothing") # available later as nibabel stores file path reference for
# loading on computation
# Save target data to a component-scoped tempfile element_tempdir = WorkDirManager().get_element_tempdir(
nifti_in_file_path = tempdir / "input.nii" # needs to be .nii prefix="afni_smoothing"
nib.save(data, nifti_in_file_path) )
# Set 3dBlurToFWHM command # Set 3dBlurToFWHM command
blur_out_path_prefix = tempdir / "blur" blur_out_path_prefix = element_tempdir / "blur"
blur_cmd = [ blur_cmd = [
"3dBlurToFWHM", "3dBlurToFWHM",
f"-input {nifti_in_file_path.resolve()}", f"-input {input['path'].resolve()}",
f"-prefix {blur_out_path_prefix.resolve()}", f"-prefix {blur_out_path_prefix.resolve()}",
"-automask", "-automask",
f"-FWHM {fwhm}", f"-FWHM {fwhm}",
@ -90,28 +86,34 @@ class AFNISmoothing:
# Call 3dBlurToFWHM # Call 3dBlurToFWHM
run_ext_cmd(name="3dBlurToFWHM", cmd=blur_cmd) run_ext_cmd(name="3dBlurToFWHM", cmd=blur_cmd)
# Create element-scoped tempdir so that the blurred output is # Read header to get output suffix
# available later as nibabel stores file path reference for header = input["data"].header
# loading on computation sform_code = header.get_sform(coded=True)[1]
element_tempdir = WorkDirManager().get_element_tempdir( if sform_code == 4:
prefix="afni_blur" output_suffix = "tlrc"
) else:
output_suffix = "orig"
# Convert afni to nifti # Convert afni to nifti
blur_afni_to_nifti_out_path = ( blur_nifti_out_path = (
element_tempdir / "output.nii" # needs to be .nii element_tempdir / "smoothed_data.nii" # needs to be .nii
) )
convert_cmd = [ convert_cmd = [
"3dAFNItoNIFTI", "3dAFNItoNIFTI",
f"-prefix {blur_afni_to_nifti_out_path.resolve()}", f"-prefix {blur_nifti_out_path.resolve()}",
f"{blur_out_path_prefix}+orig.BRIK", f"{blur_out_path_prefix}+{output_suffix}.BRIK",
] ]
# Call 3dAFNItoNIFTI # Call 3dAFNItoNIFTI
run_ext_cmd(name="3dAFNItoNIFTI", cmd=convert_cmd) run_ext_cmd(name="3dAFNItoNIFTI", cmd=convert_cmd)
logger.debug("Updating smoothed data")
input.update(
{
# Update path to sync with "data"
"path": blur_nifti_out_path,
# Load nifti # Load nifti
output_data = nib.load(blur_afni_to_nifti_out_path) "data": nib.load(blur_nifti_out_path),
}
)
# Delete tempdir return input
WorkDirManager().delete_tempdir(tempdir)
return output_data # type: ignore

View file

@ -4,7 +4,7 @@
# License: AGPL # License: AGPL
from typing import ( from typing import (
TYPE_CHECKING, Any,
ClassVar, ClassVar,
) )
@ -15,10 +15,6 @@ from ...typing import Dependencies, ExternalDependencies
from ...utils import logger, run_ext_cmd from ...utils import logger, run_ext_cmd
if TYPE_CHECKING:
from nibabel.nifti1 import Nifti1Image
__all__ = ["FSLSmoothing"] __all__ = ["FSLSmoothing"]
@ -40,16 +36,16 @@ class FSLSmoothing:
def preprocess( def preprocess(
self, self,
data: "Nifti1Image", input: dict[str, Any],
brightness_threshold: float, brightness_threshold: float,
fwhm: float, fwhm: float,
) -> "Nifti1Image": ) -> dict[str, Any]:
"""Preprocess using FSL. """Preprocess using FSL.
Parameters Parameters
---------- ----------
data : Niimg-like object input : dict
Image(s) to preprocess. A single input from the Junifer Data object in which to preprocess.
brightness_threshold : float brightness_threshold : float
Threshold to discriminate between noise and the underlying image. Threshold to discriminate between noise and the underlying image.
The value should be set greater than the noise level and less than The value should be set greater than the noise level and less than
@ -59,8 +55,8 @@ class FSLSmoothing:
Returns Returns
------- -------
Niimg-like object dict
The preprocessed image(s). The ``input`` dictionary with updated values.
Notes Notes
----- -----
@ -76,24 +72,17 @@ class FSLSmoothing:
""" """
logger.info("Smoothing using FSL") logger.info("Smoothing using FSL")
# Create component-scoped tempdir
tempdir = WorkDirManager().get_tempdir(prefix="fsl_smoothing")
# Save target data to a component-scoped tempfile
nifti_in_file_path = tempdir / "input.nii.gz"
nib.save(data, nifti_in_file_path)
# Create element-scoped tempdir so that the output is # Create element-scoped tempdir so that the output is
# available later as nibabel stores file path reference for # available later as nibabel stores file path reference for
# loading on computation # loading on computation
element_tempdir = WorkDirManager().get_element_tempdir( element_tempdir = WorkDirManager().get_element_tempdir(
prefix="fsl_susan" prefix="fsl_susan"
) )
susan_out_path = element_tempdir / "output.nii.gz" susan_out_path = element_tempdir / "smoothed_data.nii.gz"
# Set susan command # Set susan command
susan_cmd = [ susan_cmd = [
"susan", "susan",
f"{nifti_in_file_path.resolve()}", f"{input['path'].resolve()}",
f"{brightness_threshold}", f"{brightness_threshold}",
f"{fwhm}", f"{fwhm}",
"3", # dimension "3", # dimension
@ -104,10 +93,14 @@ class FSLSmoothing:
# Call susan # Call susan
run_ext_cmd(name="susan", cmd=susan_cmd) run_ext_cmd(name="susan", cmd=susan_cmd)
logger.debug("Updating smoothed data")
input.update(
{
# Update path to sync with "data"
"path": susan_out_path,
# Load nifti # Load nifti
output_data = nib.load(susan_out_path) "data": nib.load(susan_out_path),
}
)
# Delete tempdir return input
WorkDirManager().delete_tempdir(tempdir)
return output_data # type: ignore

View file

@ -4,23 +4,21 @@
# License: AGPL # License: AGPL
from typing import ( from typing import (
TYPE_CHECKING, Any,
ClassVar, ClassVar,
Literal, Literal,
Union, Union,
) )
import nibabel as nib
from nilearn import image as nimg from nilearn import image as nimg
from numpy.typing import ArrayLike from numpy.typing import ArrayLike
from ...pipeline import WorkDirManager
from ...typing import Dependencies from ...typing import Dependencies
from ...utils import logger from ...utils import logger
if TYPE_CHECKING:
from nibabel.nifti1 import Nifti1Image
__all__ = ["NilearnSmoothing"] __all__ = ["NilearnSmoothing"]
@ -35,15 +33,15 @@ class NilearnSmoothing:
def preprocess( def preprocess(
self, self,
data: "Nifti1Image", input: dict[str, Any],
fwhm: Union[int, float, ArrayLike, Literal["fast"], None], fwhm: Union[int, float, ArrayLike, Literal["fast"], None],
) -> "Nifti1Image": ) -> dict[str, Any]:
"""Preprocess using nilearn. """Preprocess using nilearn.
Parameters Parameters
---------- ----------
data : Niimg-like object input : dict
Image(s) to preprocess. A single input from the Junifer Data object in which to preprocess.
fwhm : scalar, ``numpy.ndarray``, tuple or list of scalar, "fast" or \ fwhm : scalar, ``numpy.ndarray``, tuple or list of scalar, "fast" or \
None None
Smoothing strength, as a full-width at half maximum, in Smoothing strength, as a full-width at half maximum, in
@ -61,9 +59,32 @@ class NilearnSmoothing:
Returns Returns
------- -------
Niimg-like object dict
The preprocessed image(s). The ``input`` dictionary with updated values.
""" """
logger.info("Smoothing using nilearn") logger.info("Smoothing using nilearn")
return nimg.smooth_img(imgs=data, fwhm=fwhm) # type: ignore
# Create element-scoped tempdir so that the output is
# available later as nibabel stores file path reference for
# loading on computation
element_tempdir = WorkDirManager().get_element_tempdir(
prefix="nilearn_smoothing"
)
smoothed_img = nimg.smooth_img(imgs=input["data"], fwhm=fwhm)
# Save smoothed output
smoothed_img_path = element_tempdir / "smoothed_data.nii.gz"
nib.save(smoothed_img, smoothed_img_path)
logger.debug("Updating smoothed data")
input.update(
{
# Update path to sync with "data"
"path": smoothed_img_path,
"data": smoothed_img,
}
)
return input

View file

@ -164,12 +164,9 @@ class Smoothing(BasePreprocessor):
elif self.using == "fsl": elif self.using == "fsl":
preprocessor = FSLSmoothing() preprocessor = FSLSmoothing()
# Smooth # Smooth
output = preprocessor.preprocess( # type: ignore input = preprocessor.preprocess(
data=input["data"], input=input,
**self.smoothing_params, **self.smoothing_params,
) )
# Modify target data
input["data"] = output
return input, None return input, None

View file

@ -58,9 +58,7 @@ class ANTsWarper:
Returns Returns
------- -------
dict dict
The ``input`` dictionary with modified ``data`` and ``space`` key The ``input`` dictionary with updated values.
values and new ``reference`` key whose value points to the
reference file used for warping.
Raises Raises
------ ------
@ -110,7 +108,7 @@ class ANTsWarper:
run_ext_cmd(name="ResampleImage", cmd=resample_image_cmd) run_ext_cmd(name="ResampleImage", cmd=resample_image_cmd)
# Create a tempfile for warped output # Create a tempfile for warped output
apply_transforms_out_path = element_tempdir / "output.nii.gz" apply_transforms_out_path = element_tempdir / "warped_data.nii.gz"
# Set antsApplyTransforms command # Set antsApplyTransforms command
apply_transforms_cmd = [ apply_transforms_cmd = [
"antsApplyTransforms", "antsApplyTransforms",
@ -126,14 +124,59 @@ class ANTsWarper:
# Call antsApplyTransforms # Call antsApplyTransforms
run_ext_cmd(name="antsApplyTransforms", cmd=apply_transforms_cmd) run_ext_cmd(name="antsApplyTransforms", cmd=apply_transforms_cmd)
logger.debug("Updating warped data")
input.update(
{
# Update path to sync with "data"
"path": apply_transforms_out_path,
# Load nifti # Load nifti
input["data"] = nib.load(apply_transforms_out_path) "data": nib.load(apply_transforms_out_path),
# Save resampled reference path
input["reference"] = {"path": resample_image_out_path}
# Keep pre-warp space for further operations
input["prewarp_space"] = input["space"]
# Use reference input's space as warped input's space # Use reference input's space as warped input's space
input["space"] = extra_input["T1w"]["space"] "space": extra_input["T1w"]["space"],
# Save resampled reference path
"reference": {"path": resample_image_out_path},
# Keep pre-warp space for further operations
"prewarp_space": input["space"],
}
)
# Check for data type's mask and warp if found
if input.get("mask") is not None:
# Create a tempfile for warped mask output
apply_transforms_mask_out_path = (
element_tempdir / "warped_mask.nii.gz"
)
# Set antsApplyTransforms command
apply_transforms_mask_cmd = [
"antsApplyTransforms",
"-d 3",
"-e 3",
"-n 'GenericLabel[NearestNeighbor]'",
f"-i {input['mask']['path'].resolve()}",
# use resampled reference
f"-r {input['reference']['path'].resolve()}",
f"-t {warp_file_path.resolve()}",
f"-o {apply_transforms_mask_out_path.resolve()}",
]
# Call antsApplyTransforms
run_ext_cmd(
name="antsApplyTransforms", cmd=apply_transforms_mask_cmd
)
logger.debug("Updating warped mask data")
input.update(
{
"mask": {
# Update path to sync with "data"
"path": apply_transforms_mask_out_path,
# Load nifti
"data": nib.load(apply_transforms_mask_out_path),
# Use reference input's space as warped input
# mask's space
"space": extra_input["T1w"]["space"],
}
}
)
# Template space warping # Template space warping
else: else:
@ -149,16 +192,15 @@ class ANTsWarper:
target_data=input, target_data=input,
extra_input=None, extra_input=None,
) )
# Create component-scoped tempdir
tempdir = WorkDirManager().get_tempdir(prefix="ants_warper")
# Save template # Save template
template_space_img_path = tempdir / f"{reference}_T1w.nii.gz" template_space_img_path = (
element_tempdir / f"{reference}_T1w.nii.gz"
)
nib.save(template_space_img, template_space_img_path) nib.save(template_space_img, template_space_img_path)
# Create a tempfile for warped output # Create a tempfile for warped output
warped_output_path = element_tempdir / ( warped_output_path = element_tempdir / (
f"data_warped_from_{input['space']}_to_" f"{reference}.nii.gz" f"warped_data_from_{input['space']}_to_{reference}.nii.gz"
) )
# Set antsApplyTransforms command # Set antsApplyTransforms command
@ -175,14 +217,58 @@ class ANTsWarper:
# Call antsApplyTransforms # Call antsApplyTransforms
run_ext_cmd(name="antsApplyTransforms", cmd=apply_transforms_cmd) run_ext_cmd(name="antsApplyTransforms", cmd=apply_transforms_cmd)
# Delete tempdir logger.debug("Updating warped data")
WorkDirManager().delete_tempdir(tempdir) input.update(
{
# Modify target data # Update path to sync with "data"
input["data"] = nib.load(warped_output_path) "path": warped_output_path,
# Keep pre-warp space for further operations # Load nifti
input["prewarp_space"] = input["space"] "data": nib.load(warped_output_path),
# Update warped input's space # Update warped input's space
input["space"] = reference "space": reference,
# Save reference path
"reference": {"path": template_space_img_path},
# Keep pre-warp space for further operations
"prewarp_space": input["space"],
}
)
# Check for data type's mask and warp if found
if input.get("mask") is not None:
# Create a tempfile for warped mask output
apply_transforms_mask_out_path = element_tempdir / (
f"warped_mask_from_{input['space']}_to_"
f"{reference}.nii.gz"
)
# Set antsApplyTransforms command
apply_transforms_mask_cmd = [
"antsApplyTransforms",
"-d 3",
"-e 3",
"-n 'GenericLabel[NearestNeighbor]'",
f"-i {input['mask']['path'].resolve()}",
# use resampled reference
f"-r {input['reference']['path'].resolve()}",
f"-t {xfm_file_path.resolve()}",
f"-o {apply_transforms_mask_out_path.resolve()}",
]
# Call antsApplyTransforms
run_ext_cmd(
name="antsApplyTransforms", cmd=apply_transforms_mask_cmd
)
logger.debug("Updating warped mask data")
input.update(
{
"mask": {
# Update path to sync with "data"
"path": apply_transforms_mask_out_path,
# Load nifti
"data": nib.load(apply_transforms_mask_out_path),
# Update warped input mask's space
"space": reference,
}
}
)
return input return input

View file

@ -54,9 +54,7 @@ class FSLWarper:
Returns Returns
------- -------
dict dict
The ``input`` dictionary with modified ``data`` and ``space`` key The ``input`` dictionary with updated values.
values and new ``reference`` key whose value points to the
reference file used for warping.
Raises Raises
------ ------
@ -100,26 +98,66 @@ class FSLWarper:
run_ext_cmd(name="flirt", cmd=flirt_cmd) run_ext_cmd(name="flirt", cmd=flirt_cmd)
# Create a tempfile for warped output # Create a tempfile for warped output
applywarp_out_path = element_tempdir / "output.nii.gz" applywarp_out_path = element_tempdir / "warped_data.nii.gz"
# Set applywarp command # Set applywarp command
applywarp_cmd = [ applywarp_cmd = [
"applywarp", "applywarp",
"--interp=spline", "--interp=spline",
f"-i {input['path'].resolve()}", f"-i {input['path'].resolve()}",
f"-r {flirt_out_path.resolve()}", # use resampled reference # use resampled reference
f"-r {flirt_out_path.resolve()}",
f"-w {warp_file_path.resolve()}", f"-w {warp_file_path.resolve()}",
f"-o {applywarp_out_path.resolve()}", f"-o {applywarp_out_path.resolve()}",
] ]
# Call applywarp # Call applywarp
run_ext_cmd(name="applywarp", cmd=applywarp_cmd) run_ext_cmd(name="applywarp", cmd=applywarp_cmd)
logger.debug("Updating warped data")
input.update(
{
# Update path to sync with "data"
"path": applywarp_out_path,
# Load nifti # Load nifti
input["data"] = nib.load(applywarp_out_path) "data": nib.load(applywarp_out_path),
# Save resampled reference path
input["reference"] = {"path": flirt_out_path}
# Keep pre-warp space for further operations
input["prewarp_space"] = input["space"]
# Use reference input's space as warped input's space # Use reference input's space as warped input's space
input["space"] = extra_input["T1w"]["space"] "space": extra_input["T1w"]["space"],
# Save resampled reference path
"reference": {"path": flirt_out_path},
# Keep pre-warp space for further operations
"prewarp_space": input["space"],
}
)
# Check for data type's mask and warp if found
if input.get("mask") is not None:
# Create a tempfile for warped mask output
applywarp_mask_out_path = element_tempdir / "warped_mask.nii.gz"
# Set applywarp command
applywarp_mask_cmd = [
"applywarp",
"--interp=nn",
f"-i {input['mask']['path'].resolve()}",
# use resampled reference
f"-r {input['reference']['path'].resolve()}",
f"-w {warp_file_path.resolve()}",
f"-o {applywarp_mask_out_path.resolve()}",
]
# Call applywarp
run_ext_cmd(name="applywarp", cmd=applywarp_mask_cmd)
logger.debug("Updating warped mask data")
input.update(
{
"mask": {
# Update path to sync with "data"
"path": applywarp_mask_out_path,
# Load nifti
"data": nib.load(applywarp_mask_out_path),
# Use reference input's space as warped input mask's
# space
"space": extra_input["T1w"]["space"],
}
}
)
return input return input