From c8c64e02412fb5af13205f49d4281bc591f8f8cd Mon Sep 17 00:00:00 2001 From: Gensollen Date: Fri, 2 Aug 2024 08:13:09 +0200 Subject: [PATCH] [ENH] Add the possibility to use `ANTsPy` instead of `ANTs` for `T1Linear` and `FlairLinear` (#1244) * add antspy to dependencies * try replacing n4biasfieldcorrection * implement the use-antspy option for t1-linear and flair-linear * update documentation --- clinica/pipelines/engine.py | 31 ++- .../t1_linear/anat_linear_pipeline.py | 105 ++++++++- .../pipelines/t1_linear/anat_linear_utils.py | 219 ++++++++++++++++++ .../pipelines/t1_linear/flair_linear_cli.py | 7 + clinica/pipelines/t1_linear/t1_linear_cli.py | 7 + clinica/pipelines/t1_linear/tasks.py | 50 ++++ docs/Pipelines/FLAIR_Linear.md | 62 +++-- docs/Pipelines/T1_Linear.md | 62 +++-- poetry.lock | 130 ++++++++++- pyproject.toml | 1 + .../t1_linear/test_anat_linear_utils.py | 165 +++++++++++++ 11 files changed, 742 insertions(+), 97 deletions(-) create mode 100644 clinica/pipelines/t1_linear/tasks.py diff --git a/clinica/pipelines/engine.py b/clinica/pipelines/engine.py index d055bd449..cf8b794dc 100644 --- a/clinica/pipelines/engine.py +++ b/clinica/pipelines/engine.py @@ -381,6 +381,7 @@ def __init__( base_dir: Optional[str] = None, parameters: Optional[dict] = None, name: Optional[str] = None, + ignore_dependencies: Optional[List[str]] = None, ): """Init a Pipeline object. @@ -407,6 +408,10 @@ def __init__( name : str, optional Pipeline name. Defaults to None. + ignore_dependencies : List of str + List of names of dependencies whose installation checking procedure should be ignored. + Defaults to None (i.e. all dependencies will be checked). + Raises ------ RuntimeError: [description] @@ -446,6 +451,7 @@ def __init__( self._name = name or self.__class__.__name__ self._parameters = parameters or {} + self._ignore_dependencies = ignore_dependencies or [] if not self._bids_directory: if not self._caps_directory: @@ -768,18 +774,19 @@ def _check_dependencies(self): if not self.info: self._load_info() for d in self.info["dependencies"]: - if d["type"] == "software": - check_software(d["name"]) - elif d["type"] == "binary": - check_binary(d["name"]) - elif d["type"] == "toolbox": - pass - elif d["type"] == "pipeline": - pass - else: - raise Exception( - f"Pipeline.check_dependencies() Unknown dependency type: '{d['type']}'." - ) + if d["name"] not in self._ignore_dependencies: + if d["type"] == "software": + check_software(d["name"]) + elif d["type"] == "binary": + check_binary(d["name"]) + elif d["type"] == "toolbox": + pass + elif d["type"] == "pipeline": + pass + else: + raise Exception( + f"Pipeline.check_dependencies() Unknown dependency type: '{d['type']}'." + ) self._check_custom_dependencies() return self diff --git a/clinica/pipelines/t1_linear/anat_linear_pipeline.py b/clinica/pipelines/t1_linear/anat_linear_pipeline.py index 78af3fd55..49b4510c6 100644 --- a/clinica/pipelines/t1_linear/anat_linear_pipeline.py +++ b/clinica/pipelines/t1_linear/anat_linear_pipeline.py @@ -1,7 +1,7 @@ # Use hash instead of parameters for iterables folder names # Otherwise path will be too long and generate OSError from pathlib import Path -from typing import List +from typing import List, Optional from nipype import config @@ -24,6 +24,44 @@ class AnatLinear(Pipeline): A clinica pipeline object containing the AnatLinear pipeline. """ + def __init__( + self, + bids_directory: Optional[str] = None, + caps_directory: Optional[str] = None, + tsv_file: Optional[str] = None, + overwrite_caps: Optional[bool] = False, + base_dir: Optional[str] = None, + parameters: Optional[dict] = None, + name: Optional[str] = None, + ignore_dependencies: Optional[List[str]] = None, + use_antspy: bool = False, + ): + from clinica.utils.stream import cprint + + super().__init__( + bids_directory=bids_directory, + caps_directory=caps_directory, + tsv_file=tsv_file, + overwrite_caps=overwrite_caps, + base_dir=base_dir, + parameters=parameters, + ignore_dependencies=ignore_dependencies, + name=name, + ) + self.use_antspy = use_antspy + if self.use_antspy: + self._ignore_dependencies.append("ants") + cprint( + ( + "The AnatLinear pipeline has been configured to use ANTsPy instead of ANTs.\n" + "This means that no installation of ANTs is required, but the antspyx Python " + "package must be installed in your environment.\nThis functionality has been " + "introduced in Clinica 0.9.0 and is considered experimental.\n" + "Please report any issue or unexpected results to the Clinica developer team." + ), + lvl="warning", + ) + @staticmethod def get_processed_images( caps_directory: Path, subjects: List[str], sessions: List[str] @@ -215,6 +253,10 @@ def _build_core_nodes(self): import nipype.pipeline.engine as npe from nipype.interfaces import ants + from clinica.pipelines.t1_linear.tasks import ( + run_ants_registration_task, + run_n4biasfieldcorrection_task, + ) from clinica.pipelines.tasks import crop_nifti_task, get_filename_no_ext_task from .anat_linear_utils import print_end_pipeline @@ -228,15 +270,30 @@ def _build_core_nodes(self): name="ImageID", ) - # The core (processing) nodes - # ===================================== - # 1. N4biascorrection by ANTS. It uses nipype interface. n4biascorrection = npe.Node( name="n4biascorrection", - interface=ants.N4BiasFieldCorrection(dimension=3, save_bias=True), + interface=( + nutil.Function( + function=run_n4biasfieldcorrection_task, + input_names=[ + "input_image", + "bspline_fitting_distance", + "output_prefix", + "output_dir", + "save_bias", + "verbose", + ], + output_names=["output_image"], + ) + if self.use_antspy + else ants.N4BiasFieldCorrection(dimension=3) + ), ) - + n4biascorrection.inputs.save_bias = True + if self.use_antspy: + n4biascorrection.inputs.output_dir = str(self.base_dir) + n4biascorrection.inputs.verbose = True if self.name == "t1-linear": n4biascorrection.inputs.bspline_fitting_distance = 600 else: @@ -244,14 +301,30 @@ def _build_core_nodes(self): # 2. `RegistrationSynQuick` by *ANTS*. It uses nipype interface. ants_registration_node = npe.Node( - name="antsRegistrationSynQuick", interface=ants.RegistrationSynQuick() + name="antsRegistrationSynQuick", + interface=( + nutil.Function( + function=run_ants_registration_task, + input_names=[ + "fixed_image", + "moving_image", + "random_seed", + "output_prefix", + "output_dir", + ], + output_names=["warped_image", "out_matrix"], + ) + if self.use_antspy + else ants.RegistrationSynQuick() + ), ) ants_registration_node.inputs.fixed_image = self.ref_template - ants_registration_node.inputs.transform_type = "a" - ants_registration_node.inputs.dimension = 3 + if not self.use_antspy: + ants_registration_node.inputs.transform_type = "a" + ants_registration_node.inputs.dimension = 3 - if random_seed := self.parameters.get("random_seed", None): - ants_registration_node.inputs.random_seed = random_seed + random_seed = self.parameters.get("random_seed", None) + ants_registration_node.inputs.random_seed = random_seed or 0 # 3. Crop image (using nifti). It uses custom interface, from utils file @@ -301,6 +374,16 @@ def _build_core_nodes(self): (self.input_node, print_end_message, [("anat", "anat")]), ] ) + if self.use_antspy: + self.connect( + [ + ( + image_id_node, + n4biascorrection, + [("image_id", "output_prefix")], + ), + ] + ) if not (self.parameters.get("uncropped_image")): self.connect( [ diff --git a/clinica/pipelines/t1_linear/anat_linear_utils.py b/clinica/pipelines/t1_linear/anat_linear_utils.py index 3248e558c..1f24a6e1d 100644 --- a/clinica/pipelines/t1_linear/anat_linear_utils.py +++ b/clinica/pipelines/t1_linear/anat_linear_utils.py @@ -1,3 +1,7 @@ +from pathlib import Path +from typing import Optional, Tuple + + def get_substitutions_datasink_flair(bids_image_id: str) -> list: from clinica.pipelines.t1_linear.anat_linear_utils import ( # noqa _get_substitutions_datasink, @@ -60,3 +64,218 @@ def print_end_pipeline(anat, final_file): from clinica.utils.ux import print_end_image print_end_image(get_subject_id(anat)) + + +def run_n4biasfieldcorrection( + input_image: Path, + bspline_fitting_distance: int, + output_prefix: Optional[str] = None, + output_dir: Optional[Path] = None, + save_bias: bool = False, + verbose: bool = False, +) -> Path: + """Run n4biasfieldcorrection using antsPy. + + Parameters + ---------- + input_image : Path + The path to the input image. + + bspline_fitting_distance : int + This is the 'spline_param' of n4biasfieldcorrection. + + output_prefix : str, optional + The prefix to be put at the beginning of the output file names. + Ex: 'sub-XXX_ses-MYYY'. + + output_dir : Path, optional + The directory in which to write the output files. + If not provided, these files will be written in the current directory. + + save_bias : bool, optional + Whether to save the bias image or not. + If set to True, the bias image is not returned but saved in the + provided output_dir with a name of the form '{output_prefix}_bias_image.nii.gz'. + Default=False. + + verbose : bool, optional + Control the verbose mode of n4biasfieldcorrection. Set to True can be + useful for debugging. + Default=False. + + Returns + ------- + bias_corrected_output_path : Path + The path to the bias corrected image. + """ + from clinica.utils.stream import cprint, log_and_raise + + try: + import ants + except ImportError: + log_and_raise( + "The package 'antsPy' is required to run antsRegistration in Python.", + ClinicaMissingDependencyError, + ) + + output_prefix = output_prefix or "" + bias_corrected_image = _call_n4_bias_field_correction( + input_image, bspline_fitting_distance, save_bias=False, verbose=verbose + ) + if save_bias: + bias_image = _call_n4_bias_field_correction( + input_image, + bspline_fitting_distance, + save_bias=True, + verbose=verbose, + ) + bias_output_path = ( + output_dir or Path.cwd() + ) / f"{output_prefix}_bias_image.nii.gz" + ants.image_write(bias_image, str(bias_output_path)) + cprint(f"Writing bias image to {bias_output_path}.", lvl="debug") + bias_corrected_output_path = ( + output_dir or Path.cwd() + ) / f"{output_prefix}_bias_corrected_image.nii.gz" + cprint( + f"Writing bias corrected image to {bias_corrected_output_path}.", lvl="debug" + ) + ants.image_write(bias_corrected_image, str(bias_corrected_output_path)) + + return bias_corrected_output_path + + +def _call_n4_bias_field_correction( + input_image: Path, + bspline_fitting_distance: int, + save_bias: bool = False, + verbose: bool = False, +) -> Path: + import ants + from ants.utils.bias_correction import n4_bias_field_correction + + return n4_bias_field_correction( + ants.image_read(str(input_image)), + spline_param=bspline_fitting_distance, + return_bias_field=save_bias, + verbose=verbose, + ) + + +def run_ants_registration( + fixed_image: Path, + moving_image: Path, + random_seed: int, + output_prefix: Optional[str] = None, + output_dir: Optional[Path] = None, + verbose: bool = False, +) -> Tuple[Path, Path]: + """Run antsRegistration using antsPy. + + Parameters + ---------- + fixed_image : Path + The path to the fixed image. + + moving_image : Path + The path to the moving image. + + random_seed : int + The random seed to be used. + + output_prefix : str, optional + The prefix to be put at the beginning of the output file names. + Ex: 'sub-XXX_ses-MYYY'. + + output_dir : Path, optional + The directory in which to write the output files. + If not provided, these files will be written in the current directory. + + verbose : bool, optional + Control the verbose mode of antsRegistration. Set to True can be + useful for debugging. + Default=False. + + Returns + ------- + warped_image_output_path : Path + The path to the warped nifti image generated by antsRegistration. + + transformation_matrix_output_path : Path + The path to the transforms to move from moving to fixed image. + This is a .mat file. + + Raises + ------ + RuntimeError : + If results cannot be extracted. + """ + from clinica.utils.stream import log_and_raise + + registration_results = _call_ants_registration( + fixed_image, moving_image, random_seed, verbose=verbose + ) + try: + warped_image = registration_results["warpedmovout"] + transformation_matrix = registration_results["fwdtransforms"][-1] + except (KeyError, IndexError): + msg = ( + "Something went wrong when calling antsRegistration with the following parameters :\n" + f"- fixed_image = {fixed_image}\n- moving_image = {moving_image}\n" + f"- random_seed = {random_seed}\n- type_of_transformation='antsRegistrationSyN[a]'\n" + ) + log_and_raise(msg, RuntimeError) + + return _write_ants_registration_results( + warped_image, transformation_matrix, output_prefix or "", output_dir + ) + + +def _call_ants_registration( + fixed_image: Path, + moving_image: Path, + random_seed: int, + verbose: bool = False, +) -> dict: + from clinica.utils.exceptions import ClinicaMissingDependencyError + from clinica.utils.stream import log_and_raise + + try: + import ants + except ImportError: + log_and_raise( + "The package 'antsPy' is required to run antsRegistration in Python.", + ClinicaMissingDependencyError, + ) + return ants.registration( + ants.image_read(str(fixed_image)), + ants.image_read(str(moving_image)), + type_of_transformation="antsRegistrationSyN[a]", + random_seed=random_seed, + verbose=verbose, + ) + + +def _write_ants_registration_results( + warped_image, + transformation_matrix, + output_prefix: str, + output_dir: Optional[Path] = None, +) -> Tuple[Path, Path]: + import shutil + + import ants + + from clinica.utils.stream import cprint + + warped_image_output_path = ( + output_dir or Path.cwd() + ) / f"{output_prefix}Warped.nii.gz" + transformation_matrix_output_path = ( + output_dir or Path.cwd() + ) / f"{output_prefix}0GenericAffine.mat" + cprint(f"Writing warped image to {warped_image_output_path}.", lvl="debug") + ants.image_write(warped_image, str(warped_image_output_path)) + shutil.copy(transformation_matrix, transformation_matrix_output_path) + + return warped_image_output_path, transformation_matrix_output_path diff --git a/clinica/pipelines/t1_linear/flair_linear_cli.py b/clinica/pipelines/t1_linear/flair_linear_cli.py index e1a10fc69..24a1ded25 100644 --- a/clinica/pipelines/t1_linear/flair_linear_cli.py +++ b/clinica/pipelines/t1_linear/flair_linear_cli.py @@ -26,6 +26,11 @@ @cli_param.option.working_directory @option.global_option_group @option.n_procs +@cli_param.option.option( + "--use-antspy", + is_flag=True, + help="Use ANTsPy instead of ANTs.", +) def cli( bids_directory: str, caps_directory: str, @@ -34,6 +39,7 @@ def cli( subjects_sessions_tsv: Optional[str] = None, working_directory: Optional[str] = None, n_procs: Optional[int] = None, + use_antspy: bool = False, ) -> None: """Affine registration of Flair images to the MNI standard space. @@ -59,6 +65,7 @@ def cli( base_dir=working_directory, parameters=parameters, name=pipeline_name, + use_antspy=use_antspy, ) exec_pipeline = ( diff --git a/clinica/pipelines/t1_linear/t1_linear_cli.py b/clinica/pipelines/t1_linear/t1_linear_cli.py index 5ff1607e2..5eb94e15c 100644 --- a/clinica/pipelines/t1_linear/t1_linear_cli.py +++ b/clinica/pipelines/t1_linear/t1_linear_cli.py @@ -26,6 +26,11 @@ @cli_param.option.working_directory @option.global_option_group @option.n_procs +@cli_param.option.option( + "--use-antspy", + is_flag=True, + help="Use ANTsPy instead of ANTs.", +) def cli( bids_directory: str, caps_directory: str, @@ -34,6 +39,7 @@ def cli( subjects_sessions_tsv: Optional[str] = None, working_directory: Optional[str] = None, n_procs: Optional[int] = None, + use_antspy: bool = False, ) -> None: """Affine registration of T1w images to the MNI standard space. @@ -59,6 +65,7 @@ def cli( base_dir=working_directory, parameters=parameters, name=pipeline_name, + use_antspy=use_antspy, ) exec_pipeline = ( diff --git a/clinica/pipelines/t1_linear/tasks.py b/clinica/pipelines/t1_linear/tasks.py new file mode 100644 index 000000000..73081e263 --- /dev/null +++ b/clinica/pipelines/t1_linear/tasks.py @@ -0,0 +1,50 @@ +def run_n4biasfieldcorrection_task( + input_image: str, + bspline_fitting_distance: int, + output_prefix=None, + output_dir=None, + save_bias=False, + verbose=False, +) -> str: + from pathlib import Path + + from clinica.pipelines.t1_linear.anat_linear_utils import run_n4biasfieldcorrection + + if output_dir: + output_dir = Path(output_dir) + + return str( + run_n4biasfieldcorrection( + Path(input_image), + bspline_fitting_distance, + output_prefix, + output_dir, + save_bias, + verbose, + ) + ) + + +def run_ants_registration_task( + fixed_image: str, + moving_image: str, + random_seed: int, + output_prefix=None, + output_dir=None, +) -> tuple: + from pathlib import Path + + from clinica.pipelines.t1_linear.anat_linear_utils import run_ants_registration + + if output_dir: + output_dir = Path(output_dir) + + warped_image_output_path, transformation_matrix_output_path = run_ants_registration( + Path(fixed_image), + Path(moving_image), + random_seed, + output_prefix, + output_dir, + ) + + return str(warped_image_output_path), str(transformation_matrix_output_path) diff --git a/docs/Pipelines/FLAIR_Linear.md b/docs/Pipelines/FLAIR_Linear.md index c64a213b0..74c404b00 100644 --- a/docs/Pipelines/FLAIR_Linear.md +++ b/docs/Pipelines/FLAIR_Linear.md @@ -1,22 +1,25 @@ # `flair-linear` - Affine registration of FLAIR images to the MNI standard space -This pipeline performs a set of steps in order to affinely align FLAIR -images to the MNI space using the [ANTs](http://stnava.github.io/ANTs/) -software package [[Avants et al., 2014](https://doi.org/10.3389/fninf.2014.00044)]. -These steps include: bias field correction using N4ITK -[[Tustison et al., 2010](https://doi.org/10.1109/TMI.2010.2046908)]; -affine registration to the -[MNI152NLin2009cSym](https://bids-specification.readthedocs.io/en/stable/99-appendices/08-coordinate-systems.html#template-based-coordinate-systems) -template [Fonov et al., -[2011](https://doi.org/10.1016/j.neuroimage.2010.07.033), -[2009](https://doi.org/10.1016/S1053-8119(09)70884-5)] in MNI space with the -SyN algorithm [[Avants et al., 2008](https://doi.org/10.1016/j.media.2007.06.004)]; -cropping of the registered images to remove the background. +This pipeline performs a set of steps in order to affinely align FLAIR images to the MNI space using the [ANTs](http://stnava.github.io/ANTs/) software package [[Avants et al., 2014](https://doi.org/10.3389/fninf.2014.00044)]. + +These steps include: + +- bias field correction using N4ITK [[Tustison et al., 2010](https://doi.org/10.1109/TMI.2010.2046908)] +- affine registration to the [MNI152NLin2009cSym](https://bids-specification.readthedocs.io/en/stable/99-appendices/08-coordinate-systems.html#template-based-coordinate-systems) template [Fonov et al., [2011](https://doi.org/10.1016/j.neuroimage.2010.07.033), [2009](https://doi.org/10.1016/S1053-8119(09)70884-5)] in MNI space with the SyN algorithm [[Avants et al., 2008](https://doi.org/10.1016/j.media.2007.06.004)] +- cropping of the registered images to remove the background ## Dependencies If you only installed the core of Clinica, this pipeline needs the installation of [ANTs](../Third-party.md#ants) on your computer. +!!! tip + Since clinica `0.9.0` you have the option to rely on [ANTsPy](https://antspyx.readthedocs.io/en/latest/index.html) + instead of [ANTs](../Third-party.md#ants) to run this pipeline, which means that the installation of ANTs is not + required in this case. The ANTsPy package is installed with other Python dependencies of Clinica. + To use this options, you simply need to add the `--use-antspy` option flag to the command line (see below). + Note however that this is a new and not extensively tested option such that bugs or unexpected + results are possible. **Please contact the Clinica developer team if you encounter issues**. + ## Running the pipeline The pipeline can be run with the following command line: @@ -27,40 +30,27 @@ clinica run flair-linear [OPTIONS] BIDS_DIRECTORY CAPS_DIRECTORY where: -- `BIDS_DIRECTORY` is the input folder containing the dataset in a -[BIDS](../../BIDS) hierarchy. -- `CAPS_DIRECTORY` is the output folder containing the results in a -[CAPS](../../CAPS/Introduction) hierarchy. +- `BIDS_DIRECTORY` is the input folder containing the dataset in a [BIDS](../BIDS.md) hierarchy. +- `CAPS_DIRECTORY` is the output folder containing the results in a [CAPS](../CAPS/Introduction.md) hierarchy. -On default, cropped images (matrix size 169×208×179, 1 mm isotropic voxels) are -generated to reduce the computing power required when training deep learning models. +On default, cropped images (matrix size 169×208×179, 1 mm isotropic voxels) are generated to reduce the computing power required when training deep learning models. Use the option `--uncropped_image` if you do not want to crop the image. +Finally, it is possible to use [ANTsPy](https://antspyx.readthedocs.io/en/latest/index.html) instead of [ANTs](../Third-party.md#ants) by passing the `--use-antspy` flag. + !!! note - The arguments common to all Clinica pipelines are described in - [Interacting with clinica](../../InteractingWithClinica). + The arguments common to all Clinica pipelines are described in [Interacting with clinica](../InteractingWithClinica.md). !!! tip - Do not hesitate to type `clinica run flair-linear --help` to see the full - list of parameters. + Do not hesitate to type `clinica run flair-linear --help` to see the full list of parameters. ## Outputs -Results are stored in the following folder of the [CAPS -hierarchy](../../CAPS/Specifications/#flair-linear-affine-registration-of-flair-images-to-the-mni-standard-space): -`subjects///flair_linear` with the following outputs: - -- `_space-MNI152NLin2009cSym_res-1x1x1_FLAIR.nii.gz`: -FLAIR image affinely registered to the -[`MNI152NLin2009cSym` template](https://bids-specification.readthedocs.io/en/stable/99-appendices/08-coordinate-systems.html). -- (optional) - `_space-MNI152NLin2009cSym_desc-Crop_res-1x1x1_FLAIR.nii.gz`: FLAIR - image registered to the [`MNI152NLin2009cSym` - template](https://bids-specification.readthedocs.io/en/stable/99-appendices/08-coordinate-systems.html) - and cropped. -- `_space-MNI152NLin2009cSym_res-1x1x1_affine.mat`: -affine transformation estimated with [ANTs](https://stnava.github.io/ANTs/). +Results are stored in the following folder of the [CAPS hierarchy](../../CAPS/Specifications/#flair-linear-affine-registration-of-flair-images-to-the-mni-standard-space): `subjects///flair_linear` with the following outputs: +- `_space-MNI152NLin2009cSym_res-1x1x1_FLAIR.nii.gz`: FLAIR image affinely registered to the [`MNI152NLin2009cSym` template](https://bids-specification.readthedocs.io/en/stable/99-appendices/08-coordinate-systems.html). +- (optional) `_space-MNI152NLin2009cSym_desc-Crop_res-1x1x1_FLAIR.nii.gz`: FLAIR image registered to the [`MNI152NLin2009cSym` template](https://bids-specification.readthedocs.io/en/stable/99-appendices/08-coordinate-systems.html) and cropped. +- `_space-MNI152NLin2009cSym_res-1x1x1_affine.mat`: affine transformation estimated with [ANTs](https://stnava.github.io/ANTs/). ## Describing this pipeline in your paper diff --git a/docs/Pipelines/T1_Linear.md b/docs/Pipelines/T1_Linear.md index 6c1385dd9..9bd91d6c0 100644 --- a/docs/Pipelines/T1_Linear.md +++ b/docs/Pipelines/T1_Linear.md @@ -1,27 +1,27 @@ # `t1-linear` - Affine registration of T1w images to the MNI standard space -This pipeline performs a set of steps in order to affinely align T1-weighted MR -images to the MNI space using the [ANTs](http://stnava.github.io/ANTs/) -software package [[Avants et al., 2014](https://doi.org/10.3389/fninf.2014.00044)]. -These steps include: bias field correction using N4ITK -[[Tustison et al., 2010](https://doi.org/10.1109/TMI.2010.2046908)]; -affine registration to the -[MNI152NLin2009cSym](https://bids-specification.readthedocs.io/en/stable/99-appendices/08-coordinate-systems.html#template-based-coordinate-systems) -template [Fonov et al., -[2011](https://doi.org/10.1016/j.neuroimage.2010.07.033), -[2009](https://doi.org/10.1016/S1053-8119(09)70884-5)] in MNI space with the -SyN algorithm [[Avants et al., 2008](https://doi.org/10.1016/j.media.2007.06.004)]; -cropping of the registered images to remove the background. - -This pipeline was designed as a prerequisite for the -[`extract](https://clinicadl.readthedocs.io/en/stable/Preprocessing/Extract/) and -deep learning classification algorithms presented in -[[Wen et al., 2020](https://arxiv.org/abs/1904.07773)]. +This pipeline performs a set of steps in order to affinely align T1-weighted MR images to the MNI space using the [ANTs](http://stnava.github.io/ANTs/) software package [[Avants et al., 2014](https://doi.org/10.3389/fninf.2014.00044)]. + +These steps include: + +- bias field correction using N4ITK [[Tustison et al., 2010](https://doi.org/10.1109/TMI.2010.2046908)] +- affine registration to the [MNI152NLin2009cSym](https://bids-specification.readthedocs.io/en/stable/99-appendices/08-coordinate-systems.html#template-based-coordinate-systems) template [Fonov et al., [2011](https://doi.org/10.1016/j.neuroimage.2010.07.033), [2009](https://doi.org/10.1016/S1053-8119(09)70884-5)] in MNI space with the SyN algorithm [[Avants et al., 2008](https://doi.org/10.1016/j.media.2007.06.004)] +- cropping of the registered images to remove the background + +This pipeline was designed as a prerequisite for the [`extract](https://clinicadl.readthedocs.io/en/stable/Preprocessing/Extract/) and deep learning classification algorithms presented in [[Wen et al., 2020](https://arxiv.org/abs/1904.07773)]. ## Dependencies If you only installed the core of Clinica, this pipeline needs the installation of [ANTs](../Third-party.md#ants) on your computer. +!!! tip + Since clinica `0.9.0` you have the option to rely on [ANTsPy](https://antspyx.readthedocs.io/en/latest/index.html) + instead of [ANTs](../Third-party.md#ants) to run this pipeline, which means that the installation of ANTs is not + required in this case. The ANTsPy package is installed with other Python dependencies of Clinica. + To use this options, you simply need to add the `--use-antspy` option flag to the command line (see below). + Note however that this is a new and not extensively tested option such that bugs or unexpected + results are possible. **Please contact the Clinica developer team if you encounter issues**. + ## Running the pipeline The pipeline can be run with the following command line: @@ -35,17 +35,17 @@ where: - `BIDS_DIRECTORY` is the input folder containing the dataset in a [BIDS](../BIDS.md) hierarchy. - `CAPS_DIRECTORY` is the output folder containing the results in a [CAPS](../CAPS/Introduction.md) hierarchy. -On default, cropped images (matrix size 169×208×179, 1 mm isotropic voxels) are -generated to reduce the computing power required when training deep learning models. +On default, cropped images (matrix size 169×208×179, 1 mm isotropic voxels) are generated to reduce the computing power required when training deep learning models. Use the option `--uncropped_image` if you do not want to crop the image. -It is also possible to obtain a deterministic result by setting the value of the random -seed used by ANTs with the option `--random_seed`. Default will lead to a non-deterministic result. -This option requires ANTs version `2.3.0` onwards. +It is also possible to obtain a deterministic result by setting the value of the random seed used by ANTs with the option `--random_seed`. Default will lead to a non-deterministic result. +This option requires [ANTs](../Third-party.md#ants) version `2.3.0` onwards. It is also compatible with [ANTsPy](https://antspyx.readthedocs.io/en/latest/index.html). + +Finally, it is possible to use [ANTsPy](https://antspyx.readthedocs.io/en/latest/index.html) instead of [ANTs](../Third-party.md#ants) by passing the `--use-antspy` flag. !!! note The arguments common to all Clinica pipelines are described in - [Interacting with clinica](../../InteractingWithClinica). + [Interacting with clinica](../InteractingWithClinica.md). !!! tip Do not hesitate to type `clinica run t1-linear --help` to see the full list of parameters. @@ -54,21 +54,13 @@ This option requires ANTs version `2.3.0` onwards. Results are stored in the following folder of the [CAPS hierarchy](../CAPS/Specifications.md#t1-linear---affine-registration-of-t1w-images-to-the-mni-standard-space): `subjects///t1_linear` with the following outputs: -- `_space-MNI152NLin2009cSym_res-1x1x1_T1w.nii.gz`: -T1w image affinely registered to the -[`MNI152NLin2009cSym` template](https://bids-specification.readthedocs.io/en/stable/99-appendices/08-coordinate-systems.html). -- (optional) - `_space-MNI152NLin2009cSym_desc-Crop_res-1x1x1_T1w.nii.gz`: T1w - image registered to the [`MNI152NLin2009cSym` - template](https://bids-specification.readthedocs.io/en/stable/99-appendices/08-coordinate-systems.html) - and cropped. -- `_space-MNI152NLin2009cSym_res-1x1x1_affine.mat`: -affine transformation estimated with [ANTs](https://stnava.github.io/ANTs/). +- `_space-MNI152NLin2009cSym_res-1x1x1_T1w.nii.gz`: T1w image affinely registered to the [`MNI152NLin2009cSym` template](https://bids-specification.readthedocs.io/en/stable/99-appendices/08-coordinate-systems.html). +- (optional) `_space-MNI152NLin2009cSym_desc-Crop_res-1x1x1_T1w.nii.gz`: T1w image registered to the [`MNI152NLin2009cSym` template](https://bids-specification.readthedocs.io/en/stable/99-appendices/08-coordinate-systems.html) and cropped. +- `_space-MNI152NLin2009cSym_res-1x1x1_affine.mat`: affine transformation estimated with [ANTs](https://stnava.github.io/ANTs/). ## Going further -You can now use the [ClinicaDL framework](https://clinicadl.readthedocs.io/) presented in [[Wen et al., 2020](https://doi.org/10.1016/j.media.2020.101694)] -for classification or registration quality check based on deep learning methods. +You can now use the [ClinicaDL framework](https://clinicadl.readthedocs.io/) presented in [[Wen et al., 2020](https://doi.org/10.1016/j.media.2020.101694)] for classification or registration quality check based on deep learning methods. ## Describing this pipeline in your paper diff --git a/poetry.lock b/poetry.lock index fc7538a48..23ce57290 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "abagen" @@ -35,6 +35,55 @@ files = [ {file = "ancpbids-0.2.2.tar.gz", hash = "sha256:e354974c8a6dc0b302ebbed4794e1d1d0ed87e1f069ba2c8d41af2ca88e1dfe9"}, ] +[[package]] +name = "antspyx" +version = "0.4.2" +description = "Advanced Normalization Tools in Python" +optional = false +python-versions = "*" +files = [ + {file = "antspyx-0.4.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9710a5ee4726ab9da29316a679f83e5b46b1830d27d62d2b8e6f080336bfda1a"}, + {file = "antspyx-0.4.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a451820b2bac8de83a467171cec8525a5bf75719bfc1cd0959fbba8c4262c5e9"}, + {file = "antspyx-0.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7d03d70116f4d401a682966a5330b14060d2c34d15cc2dfdbf991db5a6e0971"}, + {file = "antspyx-0.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2be915c7108f61b696f0ba7b2f064652683d001a759e2900914b73b300086275"}, + {file = "antspyx-0.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:4fcd7cd126feb334edf72fbf754377ed4f0f4a7e25332b636931afb6f2602cd6"}, + {file = "antspyx-0.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0121fb377aadb842b62d000a38d4849822ba74baa9128b7915a86face163c4a6"}, + {file = "antspyx-0.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:97b9d608d437ffa368064b7fd0fe6ecfc6cd4bd4fcef5ee460a0975a1a224c8b"}, + {file = "antspyx-0.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56cee8a67f9ef9ccbe2f1e82b12052509fa4f2b4cf8b8926124defa929f942c5"}, + {file = "antspyx-0.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:270dd23a966aa7f4109de081e1307f08c75cfeb22ceb90fff4fe0ab4dd5c8d50"}, + {file = "antspyx-0.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:dc38940e35dccf1c1f4fb6d4ef2c1973541aabc3e8a9167b232533393e6fe695"}, + {file = "antspyx-0.4.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:0d756f7db5e4bf54bd36ee6915f2fc451bf60196f26370eae80caacc1ef68623"}, + {file = "antspyx-0.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f7a1b91b3b15876749f8f4babe3eaa78e8ae2161ebe7bb5c07fcaa94c5f22dd9"}, + {file = "antspyx-0.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d37db0c0976ca0cf49cc502bb3eabc675cc14433ce5da395823b6df4069b6f5d"}, + {file = "antspyx-0.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b17c1c1f3136155c7a83cc5f29c29014ac5877fa394cf26d508303c6a4138656"}, + {file = "antspyx-0.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:654cb648cdb6863b024037e2f736cbf6c8b7fe6cd0c46f195d22c4f098e72f17"}, + {file = "antspyx-0.4.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2024887a8203c0222b02f35b670d2712db6021025e9bc9ff92a61ec873657bf8"}, + {file = "antspyx-0.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1271266ef3d23007c6c700f873894e61011604205d3a198e34a9ee802cc33479"}, + {file = "antspyx-0.4.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:39d6142c749053cfecc5f4fcedd65424f80c7251b79bc3427ea8e104e8fd18ea"}, + {file = "antspyx-0.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45ffeb3c88b49f5653215536bd06486a9655d5e07db4c8464b23a3ef28fddd35"}, + {file = "antspyx-0.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:b1ba4e71f40370203eb1b76911b91d900165c7924287848ec28033a5036d4ca6"}, + {file = "antspyx-0.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9637619d3cc27e39313e8d79bdf1b9c9a170eacb9b860afa732334ae55b185d3"}, + {file = "antspyx-0.4.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c4cfe47e8a5f1307c7d6fe0aa7f3116bfd41f84b0a02ed9a189126e0a740c130"}, + {file = "antspyx-0.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa41ad6e97356bd63dec4ff064f42b1df258f1d1bc2962e34bda56db1e3ae3b"}, + {file = "antspyx-0.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:663ade1c6a30c2c2176e3b1a3a109959f0aecd219cb91afe310a812ae5ce93e5"}, + {file = "antspyx-0.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d126bd88ddf3acc52540ebabf51805fe2c41af8e1c561477ce2b146e44738375"}, + {file = "antspyx-0.4.2.tar.gz", hash = "sha256:c9a84b9e35b68df2b1f2113af0ef4bf823dace4014e0aea5c4ff801f27aded9d"}, +] + +[package.dependencies] +chart-studio = "*" +matplotlib = "*" +nibabel = "*" +numpy = "*" +pandas = "*" +Pillow = "*" +pyyaml = "*" +scikit-image = "*" +scikit-learn = "*" +scipy = "*" +statsmodels = "*" +webcolors = "*" + [[package]] name = "argcomplete" version = "1.12.3" @@ -335,6 +384,23 @@ files = [ {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, ] +[[package]] +name = "chart-studio" +version = "1.1.0" +description = "Utilities for interfacing with plotly's Chart Studio" +optional = false +python-versions = "*" +files = [ + {file = "chart-studio-1.1.0.tar.gz", hash = "sha256:a17283b62470306d77060b200f13f9749c807dd15613c113d36f8d057f5c7019"}, + {file = "chart_studio-1.1.0-py3-none-any.whl", hash = "sha256:fd183185d6e6d31c642567145c1a862f941ca9c7695aac8b2f3ebbcbcea31a7a"}, +] + +[package.dependencies] +plotly = "*" +requests = "*" +retrying = ">=1.3.3" +six = "*" + [[package]] name = "ci-info" version = "0.3.0" @@ -2103,6 +2169,21 @@ files = [ docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] +[[package]] +name = "plotly" +version = "5.23.0" +description = "An open-source, interactive data visualization library for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "plotly-5.23.0-py3-none-any.whl", hash = "sha256:76cbe78f75eddc10c56f5a4ee3e7ccaade7c0a57465546f02098c0caed6c2d1a"}, + {file = "plotly-5.23.0.tar.gz", hash = "sha256:89e57d003a116303a34de6700862391367dd564222ab71f8531df70279fc0193"}, +] + +[package.dependencies] +packaging = "*" +tenacity = ">=6.2.0" + [[package]] name = "pluggy" version = "1.4.0" @@ -2546,7 +2627,6 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -2739,6 +2819,20 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "retrying" +version = "1.3.4" +description = "Retrying" +optional = false +python-versions = "*" +files = [ + {file = "retrying-1.3.4-py3-none-any.whl", hash = "sha256:8cc4d43cb8e1125e0ff3344e9de678fefd85db3b750b81b2240dc0183af37b35"}, + {file = "retrying-1.3.4.tar.gz", hash = "sha256:345da8c5765bd982b1d1915deb9102fd3d1f7ad16bd84a9700b85f64d24e8f3e"}, +] + +[package.dependencies] +six = ">=1.7.0" + [[package]] name = "scikit-image" version = "0.22.0" @@ -3192,6 +3286,21 @@ docs = ["nbsphinx", "packaging", "pydot (>=1.2.3)", "pydotplus", "sphinx (>=4.0, test = ["coverage", "pytest", "pytest-cov (==2.5.1)", "pytest-xdist"] tests = ["coverage", "pytest", "pytest-cov (==2.5.1)", "pytest-xdist"] +[[package]] +name = "tenacity" +version = "8.5.0" +description = "Retry code until it succeeds" +optional = false +python-versions = ">=3.8" +files = [ + {file = "tenacity-8.5.0-py3-none-any.whl", hash = "sha256:b594c2a5945830c267ce6b79a166228323ed52718f30302c1359836112346687"}, + {file = "tenacity-8.5.0.tar.gz", hash = "sha256:8bc6c0c8a09b31e6cad13c47afbed1a567518250a9a171418582ed8d9c20ca78"}, +] + +[package.extras] +doc = ["reno", "sphinx"] +test = ["pytest", "tornado (>=4.5)", "typeguard"] + [[package]] name = "threadpoolctl" version = "3.2.0" @@ -3427,6 +3536,21 @@ files = [ [package.extras] watchmedo = ["PyYAML (>=3.10)"] +[[package]] +name = "webcolors" +version = "24.6.0" +description = "A library for working with the color formats defined by HTML and CSS." +optional = false +python-versions = ">=3.8" +files = [ + {file = "webcolors-24.6.0-py3-none-any.whl", hash = "sha256:8cf5bc7e28defd1d48b9e83d5fc30741328305a8195c29a8e668fa45586568a1"}, + {file = "webcolors-24.6.0.tar.gz", hash = "sha256:1d160d1de46b3e81e58d0a280d0c78b467dc80f47294b91b1ad8029d2cedb55b"}, +] + +[package.extras] +docs = ["furo", "sphinx", "sphinx-copybutton", "sphinx-inline-tabs", "sphinx-notfound-page", "sphinxext-opengraph"] +tests = ["coverage[toml]"] + [[package]] name = "wrapt" version = "1.16.0" @@ -3577,4 +3701,4 @@ test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-it [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.13" -content-hash = "70a9b8279f22f54c4a48d98856919365c39a46cd48cc562aa3aa14bb52cc7979" +content-hash = "7d31aa45fa1dd65edb5eaeef907e77eac88ed8f5c8a20862d72beb038baa5d9c" diff --git a/pyproject.toml b/pyproject.toml index a49be56e0..245c04b16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,7 @@ pydra-bids = "^0.0.10" pydra-freesurfer = "^0.0.9" pydra-petpvc="^0.0.4" pydra-fsl = "^0.0.22" +antspyx = "^0.4.2" [tool.poetry.group.dev.dependencies] pytest = "*" diff --git a/test/unittests/pipelines/t1_linear/test_anat_linear_utils.py b/test/unittests/pipelines/t1_linear/test_anat_linear_utils.py index 859105d33..8f3388fe1 100644 --- a/test/unittests/pipelines/t1_linear/test_anat_linear_utils.py +++ b/test/unittests/pipelines/t1_linear/test_anat_linear_utils.py @@ -1,4 +1,10 @@ +from pathlib import Path +from unittest.mock import patch + +import nibabel as nib +import numpy as np import pytest +from numpy.testing import assert_array_equal @pytest.mark.parametrize("suffix", ["T1w", "FLAIR", "fooo"]) @@ -24,3 +30,162 @@ def test_get_substitutions_datasink(suffix): f"sub-ADNI022S0004_ses-M000_{suffix}Warped.nii.gz", f"sub-ADNI022S0004_ses-M000_space-MNI152NLin2009cSym_res-1x1x1_{suffix}.nii.gz", ) + + +def n4biasfieldcorrection_mock( + input_image: Path, + bspline_fitting_distance: int, + save_bias: bool = False, + verbose: bool = False, +): + """The mock simply returns the input image without any processing.""" + return nib.load(input_image) + + +def test_run_n4biasfieldcorrection_no_bias_saving(tmp_path): + from clinica.pipelines.t1_linear.anat_linear_utils import run_n4biasfieldcorrection + + data = np.random.random((10, 10, 10)) + nib.save(nib.Nifti1Image(data, np.eye(4)), tmp_path / "test.nii.gz") + output_dir = tmp_path / "out" + output_dir.mkdir() + + with patch("ants.image_write", wraps=nib.save) as image_write_mock: + with patch( + "clinica.pipelines.t1_linear.anat_linear_utils._call_n4_bias_field_correction", + wraps=n4biasfieldcorrection_mock, + ) as ants_bias_correction_mock: + bias_corrected_image = run_n4biasfieldcorrection( + tmp_path / "test.nii.gz", + bspline_fitting_distance=300, + output_prefix="sub-01_ses-M000", + output_dir=output_dir, + ) + image_write_mock.assert_called_once() + ants_bias_correction_mock.assert_called_once_with( + tmp_path / "test.nii.gz", + 300, + save_bias=False, + verbose=False, + ) + # Verify that the bias corrected image exists + # If all went well, it will be the same as the input image because of the mocks. + assert [f.name for f in output_dir.iterdir()] == [ + "sub-01_ses-M000_bias_corrected_image.nii.gz" + ] + assert bias_corrected_image.exists() + bias_corrected_nifti = nib.load(bias_corrected_image) + assert_array_equal(bias_corrected_nifti.affine, np.eye(4)) + assert_array_equal(bias_corrected_nifti.get_fdata(), data) + + +def test_run_n4biasfieldcorrection(tmp_path): + from clinica.pipelines.t1_linear.anat_linear_utils import run_n4biasfieldcorrection + + data = np.random.random((10, 10, 10)) + nib.save(nib.Nifti1Image(data, np.eye(4)), tmp_path / "test.nii.gz") + output_dir = tmp_path / "out" + output_dir.mkdir() + + with patch("ants.image_write", wraps=nib.save) as image_write_mock: + with patch( + "clinica.pipelines.t1_linear.anat_linear_utils._call_n4_bias_field_correction", + wraps=n4biasfieldcorrection_mock, + ) as ants_bias_correction_mock: + bias_corrected_image = run_n4biasfieldcorrection( + tmp_path / "test.nii.gz", + bspline_fitting_distance=300, + output_prefix="sub-01_ses-M000", + output_dir=output_dir, + save_bias=True, + verbose=True, + ) + image_write_mock.assert_called() + ants_bias_correction_mock.assert_called_with( + tmp_path / "test.nii.gz", + 300, + save_bias=True, + verbose=True, + ) + assert set([f.name for f in output_dir.iterdir()]) == { + "sub-01_ses-M000_bias_corrected_image.nii.gz", + "sub-01_ses-M000_bias_image.nii.gz", + } + assert bias_corrected_image.exists() + bias_corrected_nifti = nib.load(bias_corrected_image) + assert_array_equal(bias_corrected_nifti.affine, np.eye(4)) + assert_array_equal(bias_corrected_nifti.get_fdata(), data) + + +def generate_fake_fixed_and_moving_images(folder: Path): + data = np.random.random((10, 10, 10)) + nib.save(nib.Nifti1Image(data, np.eye(4)), folder / "fixed.nii.gz") + nib.save(nib.Nifti1Image(data, np.eye(4)), folder / "moving.nii.gz") + + +def test_run_ants_registration_error(tmp_path, mocker): + import re + + from clinica.pipelines.t1_linear.anat_linear_utils import run_ants_registration + + generate_fake_fixed_and_moving_images(tmp_path) + mocker.patch( + "clinica.pipelines.t1_linear.anat_linear_utils._call_ants_registration", + return_value={}, + ) + with pytest.raises( + RuntimeError, + match=re.escape( + "Something went wrong when calling antsRegistration with the following parameters :\n" + f"- fixed_image = {tmp_path / 'fixed.nii.gz'}\n" + f"- moving_image = {tmp_path / 'moving.nii.gz'}\n" + f"- random_seed = 0\n" + f"- type_of_transformation='antsRegistrationSyN[a]'\n" + ), + ): + run_ants_registration( + tmp_path / "fixed.nii.gz", + tmp_path / "moving.nii.gz", + random_seed=0, + ) + + +def ants_registration_mock( + fixed_image: Path, + moving_image: Path, + random_seed: int, + verbose: bool = False, +) -> dict: + workdir = fixed_image.parent / "workdir" + workdir.mkdir() + mocked_transform = workdir / "transform.mat" + mocked_transform.touch() + return { + "warpedmovout": nib.load(fixed_image), + "fwdtransforms": ["fooo.txt", mocked_transform], + "foo": "bar", + } + + +def test_run_ants_registration(tmp_path): + from clinica.pipelines.t1_linear.anat_linear_utils import run_ants_registration + + output_dir = tmp_path / "out" + output_dir.mkdir() + generate_fake_fixed_and_moving_images(tmp_path) + + with patch( + "clinica.pipelines.t1_linear.anat_linear_utils._call_ants_registration", + wraps=ants_registration_mock, + ) as mock1: + with patch("ants.image_write", wraps=nib.save) as mock2: + run_ants_registration( + tmp_path / "fixed.nii.gz", + tmp_path / "moving.nii.gz", + random_seed=12, + output_dir=output_dir, + ) + mock1.assert_called_once_with( + tmp_path / "fixed.nii.gz", tmp_path / "moving.nii.gz", 12, verbose=False + ) + mock2.assert_called_once()