From 21bbeb1c9fa8349aa7cda8bfb5bf95ca4149fd39 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Mon, 2 Dec 2024 12:57:56 +0100 Subject: [PATCH 1/5] refactor: make fMRIPrepConfoundRemover sync path and data for generated assets --- .../confounds/fmriprep_confound_remover.py | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/junifer/preprocess/confounds/fmriprep_confound_remover.py b/junifer/preprocess/confounds/fmriprep_confound_remover.py index e8a994db4..6129a1a71 100644 --- a/junifer/preprocess/confounds/fmriprep_confound_remover.py +++ b/junifer/preprocess/confounds/fmriprep_confound_remover.py @@ -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 -- 2.52.0 From 09aa722b2a60f40265ae1e99bd51b041a7bd29d5 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Mon, 2 Dec 2024 12:59:57 +0100 Subject: [PATCH 2/5] refactor: make Smoothing sync path and data for generated assets --- .../preprocess/smoothing/_afni_smoothing.py | 72 ++++++++++--------- .../preprocess/smoothing/_fsl_smoothing.py | 45 +++++------- .../smoothing/_nilearn_smoothing.py | 45 ++++++++---- junifer/preprocess/smoothing/smoothing.py | 7 +- 4 files changed, 91 insertions(+), 78 deletions(-) diff --git a/junifer/preprocess/smoothing/_afni_smoothing.py b/junifer/preprocess/smoothing/_afni_smoothing.py index 6790b7b8b..53c14292f 100644 --- a/junifer/preprocess/smoothing/_afni_smoothing.py +++ b/junifer/preprocess/smoothing/_afni_smoothing.py @@ -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) - # Load nifti - output_data = nib.load(blur_afni_to_nifti_out_path) + logger.debug("Updating smoothed data") + input.update( + { + # Update path to sync with "data" + "path": blur_nifti_out_path, + # Load nifti + "data": nib.load(blur_nifti_out_path), + } + ) - # Delete tempdir - WorkDirManager().delete_tempdir(tempdir) - - return output_data # type: ignore + return input diff --git a/junifer/preprocess/smoothing/_fsl_smoothing.py b/junifer/preprocess/smoothing/_fsl_smoothing.py index b6c34ca86..88d21b3a1 100644 --- a/junifer/preprocess/smoothing/_fsl_smoothing.py +++ b/junifer/preprocess/smoothing/_fsl_smoothing.py @@ -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) - # Load nifti - output_data = nib.load(susan_out_path) + logger.debug("Updating smoothed data") + input.update( + { + # Update path to sync with "data" + "path": susan_out_path, + # Load nifti + "data": nib.load(susan_out_path), + } + ) - # Delete tempdir - WorkDirManager().delete_tempdir(tempdir) - - return output_data # type: ignore + return input diff --git a/junifer/preprocess/smoothing/_nilearn_smoothing.py b/junifer/preprocess/smoothing/_nilearn_smoothing.py index 304e700a4..cc6f5c381 100644 --- a/junifer/preprocess/smoothing/_nilearn_smoothing.py +++ b/junifer/preprocess/smoothing/_nilearn_smoothing.py @@ -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 diff --git a/junifer/preprocess/smoothing/smoothing.py b/junifer/preprocess/smoothing/smoothing.py index d20bd153c..810dcbb25 100644 --- a/junifer/preprocess/smoothing/smoothing.py +++ b/junifer/preprocess/smoothing/smoothing.py @@ -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 -- 2.52.0 From 39673394e7b3f9574a1497176d272478192a808a Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Mon, 2 Dec 2024 13:07:14 +0100 Subject: [PATCH 3/5] style: clean up and update docstrings for ANTsWarper and FSLWarper --- junifer/preprocess/warping/_ants_warper.py | 61 +++++++++++++--------- junifer/preprocess/warping/_fsl_warper.py | 33 +++++++----- 2 files changed, 56 insertions(+), 38 deletions(-) diff --git a/junifer/preprocess/warping/_ants_warper.py b/junifer/preprocess/warping/_ants_warper.py index f1bbbe2b5..4b74bd887 100644 --- a/junifer/preprocess/warping/_ants_warper.py +++ b/junifer/preprocess/warping/_ants_warper.py @@ -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,21 @@ class ANTsWarper: # Call antsApplyTransforms run_ext_cmd(name="antsApplyTransforms", cmd=apply_transforms_cmd) - # 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"] - # Use reference input's space as warped input's space - input["space"] = extra_input["T1w"]["space"] + logger.debug("Updating warped data") + input.update( + { + # Update path to sync with "data" + "path": apply_transforms_out_path, + # Load nifti + "data": nib.load(apply_transforms_out_path), + # Use reference input's space as warped input's 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"], + } + ) # Template space warping else: @@ -149,16 +154,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 +179,21 @@ class ANTsWarper: # Call antsApplyTransforms run_ext_cmd(name="antsApplyTransforms", cmd=apply_transforms_cmd) - # Delete tempdir - WorkDirManager().delete_tempdir(tempdir) + 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 + "space": reference, + # Save reference path + "reference": {"path": template_space_img_path}, + # Keep pre-warp space for further operations + "prewarp_space": input["space"], + } + ) - # Modify target data - input["data"] = nib.load(warped_output_path) - # Keep pre-warp space for further operations - input["prewarp_space"] = input["space"] - # Update warped input's space - input["space"] = reference return input diff --git a/junifer/preprocess/warping/_fsl_warper.py b/junifer/preprocess/warping/_fsl_warper.py index d504c5694..3fc16b8d1 100644 --- a/junifer/preprocess/warping/_fsl_warper.py +++ b/junifer/preprocess/warping/_fsl_warper.py @@ -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,35 @@ 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) - # 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"] - # Use reference input's space as warped input's space - input["space"] = extra_input["T1w"]["space"] + logger.debug("Updating warped data") + input.update( + { + # Update path to sync with "data" + "path": applywarp_out_path, + # Load nifti + "data": nib.load(applywarp_out_path), + # Use reference input's space as warped input's 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"], + } + ) + return input -- 2.52.0 From 8f2de4230c53b9ce6692d52771c72750dcf3e2cc Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Mon, 2 Dec 2024 13:08:53 +0100 Subject: [PATCH 4/5] fix: warp masks if found for SpaceWarper implementations --- junifer/preprocess/warping/_ants_warper.py | 75 ++++++++++++++++++++++ junifer/preprocess/warping/_fsl_warper.py | 31 +++++++++ 2 files changed, 106 insertions(+) diff --git a/junifer/preprocess/warping/_ants_warper.py b/junifer/preprocess/warping/_ants_warper.py index 4b74bd887..ed9ab4ca2 100644 --- a/junifer/preprocess/warping/_ants_warper.py +++ b/junifer/preprocess/warping/_ants_warper.py @@ -140,6 +140,44 @@ class ANTsWarper: } ) + # 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: logger.debug( @@ -195,5 +233,42 @@ class ANTsWarper: } ) + # 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 diff --git a/junifer/preprocess/warping/_fsl_warper.py b/junifer/preprocess/warping/_fsl_warper.py index 3fc16b8d1..2d66982f9 100644 --- a/junifer/preprocess/warping/_fsl_warper.py +++ b/junifer/preprocess/warping/_fsl_warper.py @@ -128,5 +128,36 @@ class FSLWarper: } ) + # 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 -- 2.52.0 From 3ef304d99ae26048efce60bbc1bf278c2d67647c Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Mon, 2 Dec 2024 13:30:59 +0100 Subject: [PATCH 5/5] chore: update 408.{enh,feature} --- docs/changes/newsfragments/408.enh | 1 + docs/changes/newsfragments/408.feature | 1 + 2 files changed, 2 insertions(+) create mode 100644 docs/changes/newsfragments/408.enh create mode 100644 docs/changes/newsfragments/408.feature diff --git a/docs/changes/newsfragments/408.enh b/docs/changes/newsfragments/408.enh new file mode 100644 index 000000000..7d92a5355 --- /dev/null +++ b/docs/changes/newsfragments/408.enh @@ -0,0 +1 @@ +Update Preprocessors to sync data their paths so that the results are idempotent by `Synchon Mandal`_ diff --git a/docs/changes/newsfragments/408.feature b/docs/changes/newsfragments/408.feature new file mode 100644 index 000000000..d3d0e12b1 --- /dev/null +++ b/docs/changes/newsfragments/408.feature @@ -0,0 +1 @@ +Allow masks to be warped, if found, in :class:`.SpaceWarper` by `Synchon Mandal`_ -- 2.52.0