[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,
)
import nibabel as nib
import numpy as np
import pandas as pd
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 ...data import get_data
from ...pipeline import WorkDirManager
from ...typing import Dependencies
from ...utils import logger, raise_error
from ..base import BasePreprocessor
@ -539,9 +541,17 @@ class fMRIPrepConfoundRemover(BasePreprocessor):
logger.info(
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
mask_img = None
if self.masks is not None:
# Generate mask
logger.debug(f"Masking with {self.masks}")
mask_img = get_data(
kind="mask",
@ -549,13 +559,21 @@ class fMRIPrepConfoundRemover(BasePreprocessor):
target_data=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
logger.debug("Setting `BOLD.mask`")
input.update(
{
"mask": {
# Update path to sync with "data"
"path": generated_mask_img_path,
# Update data
"data": mask_img,
# Should be in the same space as target data
"space": input["space"],
}
}
@ -566,7 +584,9 @@ class fMRIPrepConfoundRemover(BasePreprocessor):
logger.debug(f"\tstandardize: {self.standardize}")
logger.debug(f"\tlow_pass: {self.low_pass}")
logger.debug(f"\thigh_pass: {self.high_pass}")
input["data"] = nimg.clean_img(
# Deconfound data
cleaned_img = nimg.clean_img(
imgs=bold_img,
detrend=self.detrend,
standardize=self.standardize,
@ -576,5 +596,18 @@ class fMRIPrepConfoundRemover(BasePreprocessor):
t_r=t_r,
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

View file

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

View file

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

View file

@ -4,23 +4,21 @@
# License: AGPL
from typing import (
TYPE_CHECKING,
Any,
ClassVar,
Literal,
Union,
)
import nibabel as nib
from nilearn import image as nimg
from numpy.typing import ArrayLike
from ...pipeline import WorkDirManager
from ...typing import Dependencies
from ...utils import logger
if TYPE_CHECKING:
from nibabel.nifti1 import Nifti1Image
__all__ = ["NilearnSmoothing"]
@ -35,15 +33,15 @@ class NilearnSmoothing:
def preprocess(
self,
data: "Nifti1Image",
input: dict[str, Any],
fwhm: Union[int, float, ArrayLike, Literal["fast"], None],
) -> "Nifti1Image":
) -> dict[str, Any]:
"""Preprocess using nilearn.
Parameters
----------
data : Niimg-like object
Image(s) to preprocess.
input : dict
A single input from the Junifer Data object in which to preprocess.
fwhm : scalar, ``numpy.ndarray``, tuple or list of scalar, "fast" or \
None
Smoothing strength, as a full-width at half maximum, in
@ -61,9 +59,32 @@ class NilearnSmoothing:
Returns
-------
Niimg-like object
The preprocessed image(s).
dict
The ``input`` dictionary with updated values.
"""
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":
preprocessor = FSLSmoothing()
# Smooth
output = preprocessor.preprocess( # type: ignore
data=input["data"],
input = preprocessor.preprocess(
input=input,
**self.smoothing_params,
)
# Modify target data
input["data"] = output
return input, None

View file

@ -58,9 +58,7 @@ class ANTsWarper:
Returns
-------
dict
The ``input`` dictionary with modified ``data`` and ``space`` key
values and new ``reference`` key whose value points to the
reference file used for warping.
The ``input`` dictionary with updated values.
Raises
------
@ -110,7 +108,7 @@ class ANTsWarper:
run_ext_cmd(name="ResampleImage", cmd=resample_image_cmd)
# 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
apply_transforms_cmd = [
"antsApplyTransforms",
@ -126,14 +124,59 @@ class ANTsWarper:
# Call antsApplyTransforms
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
input["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"]
"data": nib.load(apply_transforms_out_path),
# 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
else:
@ -149,16 +192,15 @@ class ANTsWarper:
target_data=input,
extra_input=None,
)
# Create component-scoped tempdir
tempdir = WorkDirManager().get_tempdir(prefix="ants_warper")
# 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)
# Create a tempfile for warped output
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
@ -175,14 +217,58 @@ class ANTsWarper:
# Call antsApplyTransforms
run_ext_cmd(name="antsApplyTransforms", cmd=apply_transforms_cmd)
# Delete tempdir
WorkDirManager().delete_tempdir(tempdir)
# Modify target data
input["data"] = nib.load(warped_output_path)
# Keep pre-warp space for further operations
input["prewarp_space"] = input["space"]
logger.debug("Updating warped data")
input.update(
{
# Update path to sync with "data"
"path": warped_output_path,
# Load nifti
"data": nib.load(warped_output_path),
# 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

View file

@ -54,9 +54,7 @@ class FSLWarper:
Returns
-------
dict
The ``input`` dictionary with modified ``data`` and ``space`` key
values and new ``reference`` key whose value points to the
reference file used for warping.
The ``input`` dictionary with updated values.
Raises
------
@ -100,26 +98,66 @@ class FSLWarper:
run_ext_cmd(name="flirt", cmd=flirt_cmd)
# 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
applywarp_cmd = [
"applywarp",
"--interp=spline",
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"-o {applywarp_out_path.resolve()}",
]
# Call applywarp
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
input["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"]
"data": nib.load(applywarp_out_path),
# 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