[ENH]: Support for multiple computed masks #175

Merged
fraimondo merged 10 commits from feat/compute_mask into main 2023-01-23 17:32:41 +00:00
17 changed files with 375 additions and 45 deletions

View file

@ -515,7 +515,7 @@ Available
* - Name
- Keys
- Version added
- Publication
- Description - Publication
* - Vickery-Patil (Gray Matter)
- | ``GM_prob0.2``
- 0.0.1
@ -528,6 +528,29 @@ Available
- | Vickery, Sam, & Patil, Kaustubh. (2022).
| Chimpanzee and Human Gray Matter Masks [Data set]. Zenodo.
| https://doi.org/10.5281/zenodo.6463123
* - Nilearn's MNI152 1mm-resolution mask
- | ``compute_brain_mask``
- 0.0.2
- | Compute the whole-brain mask. This mask is calculated using MNI152 1mm-resolution template mask onto the
| target image. See :func:`nilearn.masking.compute_brain_mask`
* - Nilearn's mask computed from FMRI data
- | ``compute_epi_mask``
- 0.0.2
- | Compute a brain mask from fMRI data. This is based on an heuristic proposed by T.Nichols: find the least
| dense point of the histogram, between fractions ``lower_cutoff`` and ``upper_cutoff`` of the total image
| histogram. See :func:`nilearn.masking.compute_epi_mask`
* - Nilearn's background mask
- | ``compute_background_mask``
- 0.0.2
- | Compute a brain mask for the images by guessing the value of the background from the border of the image.
| See :func:`nilearn.masking.compute_background_mask`
* - Nilearn's ICBM152 template gray-matter mask
- | ``fetch_icbm152_brain_gm_mask``
- 0.0.2
- | Compute a gray-matter mask from the asymmetrical ICBM152 2009 template, release a.
| See :func:`nilearn.datasets.fetch_icbm152_brain_gm_mask`
Planned
~~~~~~~

View file

@ -37,6 +37,9 @@ Enhancements
- Change HCP datagrabber tests to decrease CI running time (:gh:`155` by `Fede Raimondo`_).
- Add support for nilearn computed masks (``compute_epi_mask``, ``compute_brain_mask``, ``compute_background_mask``,
``fetch_icbm152_brain_gm_mask``) (:gh:`175` by `Fede Raimondo`_).
Bugs
~~~~

View file

@ -19,6 +19,7 @@ from .masks import (
list_masks,
load_mask,
register_mask,
get_mask,
)
from . import utils

View file

@ -4,9 +4,27 @@
# License: AGPL
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union
from typing import (
TYPE_CHECKING,
Any,
Dict,
List,
Optional,
Tuple,
Union,
Callable,
)
import numpy as np
import nibabel as nib
from nilearn.masking import (
compute_brain_mask,
compute_background_mask,
compute_epi_mask,
)
from nilearn.datasets import fetch_icbm152_brain_gm_mask
from nilearn.image import resample_to_img
from ..utils.logging import logger, raise_error
from .utils import closest_resolution
@ -18,6 +36,30 @@ if TYPE_CHECKING:
# Path to the VOIs
synchon commented 2023-01-19 10:44:16 +00:00 (Migrated from github.com)

Missing type annotations.

Missing type annotations.
synchon commented 2023-01-19 10:44:42 +00:00 (Migrated from github.com)

**kwargs : dict

`**kwargs : dict`
_masks_path = Path(__file__).parent / "masks"
def _fetch_icbm152_brain_gm_mask(target_img: "Nifti1Image", **kwargs):
"""Fetch ICBM152 brain mask and resample.
Parameters
----------
target_img : nibabel.Nifti1Image
LeSasse commented 2023-01-18 07:57:34 +00:00 (Migrated from github.com)

I think it would be better to have the actual function name from nilearn (i.e. "compute_brain_mask") rather than an abbreviation of it.

I think it would be better to have the actual function name from nilearn (i.e. "compute_brain_mask") rather than an abbreviation of it.
The image to which the mask will be resampled.
**kwargs : dict
Keyword arguments to be passed to
:func:`nilearn.datasets.fetch_icbm152_brain_gm_mask`.
Returns
-------
nibabel.Nifti1Image
The resampled mask.
"""
mask = fetch_icbm152_brain_gm_mask(**kwargs)
mask = resample_to_img(
mask, target_img, interpolation="nearest", copy=True
)
return mask
"""
A dictionary containing all supported masks and their respective file or
data.
@ -28,6 +70,22 @@ data/masks directory. The user can also register their own masks.
_available_masks: Dict[str, Dict[str, Any]] = {
"GM_prob0.2": {"family": "Vickery-Patil"},
"GM_prob0.2_cortex": {"family": "Vickery-Patil"},
"compute_brain_mask": {
"family": "Callable",
"func": compute_brain_mask,
},
"compute_background_mask": {
"family": "Callable",
"func": compute_background_mask,
},
"compute_epi_mask": {
"family": "Callable",
"func": compute_epi_mask,
},
"fetch_icbm152_brain_gm_mask": {
"family": "Callable",
"func": _fetch_icbm152_brain_gm_mask,
},
}
@ -88,11 +146,67 @@ def list_masks() -> List[str]:
return sorted(_available_masks.keys())
synchon commented 2023-01-19 10:53:31 +00:00 (Migrated from github.com)

Would prefer to have the type as dict and the key-value pair information in the description below.

Would prefer to have the type as `dict` and the key-value pair information in the description below.
synchon commented 2023-01-19 10:55:27 +00:00 (Migrated from github.com)

Missing coverage here.

Missing coverage here.
def get_mask(
mask: Union[str, Dict],
target_data: Dict[str, Any],
) -> "Nifti1Image":
"""Get mask, tailored for the target image.
Parameters
----------
masks : str or dict
The name of the mask, or the name of a callable mask and the parameters
of the mask.
target_data : dict
The corresponding item of the data object to which the mask will be
applied.
Returns
-------
Nifti1Image
The mask image.
"""
# Get the min of the voxels sizes and use it as the resolution
target_img = target_data["data"]
resolution = np.min(target_img.header.get_zooms()[:3])
if isinstance(mask, dict):
if len(mask) != 1:
raise_error(
"The mask dictionary must have only one key, "
"the name of the mask."
)
mask_name = list(mask.keys())[0]
mask_params = mask[mask_name]
else:
mask_name = mask
mask_params = None
mask_object, _ = load_mask(
mask_name, path_only=False, resolution=resolution
)
if callable(mask_object):
if mask_params is None:
mask_params = {}
mask_img = mask_object(target_img, **mask_params)
else: # Mask is a Nifti1Image
if mask_params is not None:
raise_error("Cannot pass callable params to a non-callable mask.")
mask_img = resample_to_img(
mask_object,
target_img,
interpolation="nearest",
copy=True,
)
return mask_img
def load_mask(
name: str,
resolution: Optional[float] = None,
path_only: bool = False,
) -> Tuple[Optional["Nifti1Image"], Path]:
) -> Tuple[Optional[Union["Nifti1Image", Callable]], Optional[Path]]:
"""Load mask.
Parameters
@ -109,11 +223,12 @@ def load_mask(
Returns
-------
Nifti1Image or None
Nifti1Image, Callable or None
Loaded mask image.
pathlib.Path
pathlib.Path or None
File path to the mask image.
"""
mask_img = None
if name not in _available_masks:
raise_error(
f"Mask {name} not found. " f"Valid options are: {list_masks()}"
@ -126,12 +241,14 @@ def load_mask(
mask_fname = Path(mask_definition["path"])
elif t_family == "Vickery-Patil":
mask_fname = _load_vickery_patil_mask(name, resolution)
elif t_family == "Callable":
mask_img = mask_definition["func"]
synchon commented 2023-01-19 10:54:00 +00:00 (Migrated from github.com)

Needs a newline after this line.

Needs a newline after this line.
mask_fname = None
else:
raise_error(f"I don't know about the {t_family} mask family.")
if mask_fname is not None:
logger.info(f"Loading mask {mask_fname.absolute()}")
mask_img = None
if path_only is False:
mask_img = nib.load(mask_fname)

View file

@ -5,16 +5,37 @@
# Synchon Mandal <s.mandal@fz-juelich.de>
# License: AGPL
from typing import Callable, Dict, Union
from pathlib import Path
import pytest
from numpy.testing import assert_array_almost_equal
from numpy.testing import (
assert_array_almost_equal,
assert_array_equal,
)
from nilearn.image import resample_to_img
from nilearn.masking import (
compute_brain_mask,
compute_background_mask,
compute_epi_mask,
)
from nilearn.datasets import fetch_icbm152_brain_gm_mask
from junifer.data.masks import (
_load_vickery_patil_mask,
list_masks,
load_mask,
register_mask,
get_mask,
_available_masks,
)
from junifer.datareader import DefaultDataReader
from junifer.testing.datagrabbers import (
OasisVBMTestingDatagrabber,
SPMAuditoryTestingDatagrabber,
)
@ -151,3 +172,124 @@ def test_vickery_patil() -> None:
with pytest.raises(ValueError, match=r"find a Vickery-Patil mask "):
_load_vickery_patil_mask("wrong", resolution=2)
def test_get_mask() -> None:
"""Test the get_mask function."""
reader = DefaultDataReader()
with OasisVBMTestingDatagrabber() as dg:
input = dg["sub-01"]
input = reader.fit_transform(input)
vbm_gm = input["VBM_GM"]
vbm_gm_img = vbm_gm["data"]
mask = get_mask(mask="GM_prob0.2", target_data=vbm_gm)
assert mask.shape == vbm_gm_img.shape
assert_array_equal(mask.affine, vbm_gm_img.affine)
raw_mask_img, _ = load_mask("GM_prob0.2", resolution=1.5)
res_mask_img = resample_to_img(
raw_mask_img,
vbm_gm_img,
interpolation="nearest",
copy=True,
)
assert_array_equal(mask.get_fdata(), res_mask_img.get_fdata())
def test_mask_callable() -> None:
"""Test using a callable mask."""
def ident(x):
return x
_available_masks["identity"] = {"family": "Callable", "func": ident}
reader = DefaultDataReader()
with OasisVBMTestingDatagrabber() as dg:
input = dg["sub-01"]
input = reader.fit_transform(input)
vbm_gm = input["VBM_GM"]
vbm_gm_img = vbm_gm["data"]
mask = get_mask(mask="identity", target_data=vbm_gm)
assert_array_equal(mask.get_fdata(), vbm_gm_img.get_fdata())
del _available_masks["identity"]
def test_get_mask_errors() -> None:
"""Test passing wrong parameters to get_mask."""
reader = DefaultDataReader()
with OasisVBMTestingDatagrabber() as dg:
input = dg["sub-01"]
input = reader.fit_transform(input)
vbm_gm = input["VBM_GM"]
with pytest.raises(ValueError, match=r"only one key"):
get_mask(mask={"GM_prob0.2": {}, "Other": {}}, target_data=vbm_gm)
with pytest.raises(ValueError, match=r"callable params"):
get_mask(mask={"GM_prob0.2": {"param": 1}}, target_data=vbm_gm)
@pytest.mark.parametrize(
"mask_name,function,params,resample",
[
("compute_brain_mask", compute_brain_mask, {"threshold": 0.2}, False),
("compute_background_mask", compute_background_mask, None, False),
("compute_epi_mask", compute_epi_mask, None, False),
(
"fetch_icbm152_brain_gm_mask",
fetch_icbm152_brain_gm_mask,
None,
True,
),
],
)
def test_nilearn_compute_masks(
mask_name: str,
function: Callable,
params: Union[Dict, None],
resample: bool,
) -> None:
"""Test using nilearn compute mask functions.
Parameters
----------
mask_name : str
Name of the mask.
function : callable
Function to call.
params : dict, optional
Parameters to pass to the function.
resample : bool
Whether to resample the mask to the target data.
"""
reader = DefaultDataReader()
with SPMAuditoryTestingDatagrabber() as dg:
input = dg["sub001"]
input = reader.fit_transform(input)
bold = input["BOLD"]
bold_img = bold["data"]
if params is None:
params = {}
mask_spec = mask_name
else:
mask_spec = {mask_name: params}
mask = get_mask(mask=mask_spec, target_data=bold)
assert_array_equal(mask.affine, bold_img.affine)
if resample is False:
ni_mask = function(bold_img, **params)
else:
ni_mask = function(**params)
# Mask needs resample
ni_mask = resample_to_img(
ni_mask,
bold_img,
interpolation="nearest",
copy=True,
)
assert_array_equal(mask.get_fdata(), ni_mask.get_fdata())

View file

@ -49,7 +49,7 @@ class RSSETSMarker(BaseMarker):
parcellation: Union[str, List[str]],
agg_method: str = "mean",
agg_method_params: Optional[Dict] = None,
mask: Optional[str] = None,
mask: Union[str, Dict, None] = None,
name: Optional[str] = None,
) -> None:
self.parcellation = parcellation

View file

@ -70,7 +70,7 @@ class AmplitudeLowFrequencyFluctuationParcels(
lowpass: float = 0.1,
tr: Optional[float] = None,
use_afni: Optional[bool] = None,
mask: Optional[str] = None,
mask: Union[str, Dict, None] = None,
method: str = "mean",
method_params: Optional[Dict] = None,
name: Optional[str] = None,

View file

@ -5,7 +5,7 @@
# Kaustubh R. Patil <k.patil@fz-juelich.de>
# License: AGPL
from typing import Dict, Optional
from typing import Dict, Optional, Union
from ...api.decorators import register_marker
from .. import SphereAggregation
@ -75,7 +75,7 @@ class AmplitudeLowFrequencyFluctuationSpheres(
lowpass: float = 0.1,
tr: Optional[float] = None,
use_afni: Optional[bool] = None,
mask: Optional[str] = None,
mask: Union[str, Dict, None] = None,
method: str = "mean",
method_params: Optional[Dict] = None,
name: Optional[str] = None,

View file

@ -4,7 +4,7 @@
# Kaustubh R. Patil <k.patil@fz-juelich.de>
# License: AGPL
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Union
import pandas as pd
@ -47,7 +47,7 @@ class CrossParcellationFC(BaseMarker):
parcellation_two: str,
aggregation_method: str = "mean",
correlation_method: str = "pearson",
mask: Optional[str] = None,
mask: Union[str, Dict, None] = None,
name: Optional[str] = None,
) -> None:
if parcellation_one == parcellation_two:

View file

@ -5,7 +5,7 @@
from abc import abstractmethod
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Union
from nilearn.connectome import ConnectivityMeasure
from sklearn.covariance import EmpiricalCovariance
@ -50,7 +50,7 @@ class FunctionalConnectivityBase(BaseMarker):
agg_method_params: Optional[Dict] = None,
cor_method: str = "covariance",
cor_method_params: Optional[Dict] = None,
mask: Optional[str] = None,
mask: Union[str, Dict, None] = None,
name: Optional[str] = None,
) -> None:
self.agg_method = agg_method

View file

@ -51,7 +51,7 @@ class FunctionalConnectivityParcels(FunctionalConnectivityBase):
agg_method_params: Optional[Dict] = None,
cor_method: str = "covariance",
cor_method_params: Optional[Dict] = None,
mask: Optional[str] = None,
mask: Union[str, Dict, None] = None,
name: Optional[str] = None,
) -> None:
self.parcellation = parcellation

View file

@ -5,7 +5,7 @@
# Synchon Mandal <s.mandal@fz-juelich.de>
# License: AGPL
from typing import Any, Dict, Optional
from typing import Any, Dict, Optional, Union
from ...api.decorators import register_marker
from ..sphere_aggregation import SphereAggregation
@ -57,7 +57,7 @@ class FunctionalConnectivitySpheres(FunctionalConnectivityBase):
agg_method_params: Optional[Dict] = None,
cor_method: str = "covariance",
cor_method_params: Optional[Dict] = None,
mask: Optional[str] = None,
mask: Union[str, Dict, None] = None,
name: Optional[str] = None,
) -> None:
self.coords = coords

View file

@ -11,7 +11,7 @@ from nilearn.image import math_img, new_img_like, resample_to_img
from nilearn.maskers import NiftiMasker
LeSasse commented 2023-01-18 14:25:51 +00:00 (Migrated from github.com)

same here: in the ParcelAggregation init method should masks not be this should be str or list of (str or dict) now?

same here: in the ParcelAggregation init method should masks not be this should be str or list of (str or dict) now?
fraimondo commented 2023-01-18 14:55:49 +00:00 (Migrated from github.com)

same as before. This is for computing masks.

same as before. This is for computing masks.
LeSasse commented 2023-01-18 14:59:47 +00:00 (Migrated from github.com)

i see makes sense!

i see makes sense!
from ..api.decorators import register_marker
from ..data import load_mask, load_parcellation
from ..data import get_mask, load_parcellation
from ..stats import get_aggfunc_by_name
from ..utils import logger
from .base import BaseMarker
@ -52,7 +52,7 @@ class ParcelAggregation(BaseMarker):
parcellation: Union[str, List[str]],
method: str,
method_params: Optional[Dict[str, Any]] = None,
mask: Optional[str] = None,
mask: Union[str, Dict, None] = None,
on: Union[List[str], str, None] = None,
name: Optional[str] = None,
) -> None:
@ -126,14 +126,14 @@ class ParcelAggregation(BaseMarker):
* ``columns`` : the column labels for the computed values as a list
"""
t_input = input["data"]
t_input_img = input["data"]
logger.debug(f"Parcel aggregation using {self.method}")
agg_func = get_aggfunc_by_name(
name=self.method,
func_params=self.method_params,
)
# Get the min of the voxels sizes and use it as the resolution
resolution = np.min(t_input.header.get_zooms()[:3])
resolution = np.min(t_input_img.header.get_zooms()[:3])
# Load the parcellations
all_parcelations = []
@ -146,7 +146,7 @@ class ParcelAggregation(BaseMarker):
# Resample all of them to the image
t_parcellation_img_res = resample_to_img(
t_parcellation,
t_input,
t_input_img,
interpolation="nearest",
copy=True,
)
@ -186,13 +186,8 @@ class ParcelAggregation(BaseMarker):
if self.mask is not None:
logger.debug(f"Masking with {self.mask}")
mask_img, _ = load_mask(name=self.mask, resolution=resolution)
mask_img = resample_to_img(
mask_img,
t_input,
interpolation="nearest",
copy=True,
)
mask_img = get_mask(mask=self.mask, target_data=input)
parcellation_bin = math_img(
"np.logical_and(img, mask)",
img=parcellation_bin,
@ -201,11 +196,11 @@ class ParcelAggregation(BaseMarker):
logger.debug("Masking")
masker = NiftiMasker(
parcellation_bin, target_affine=t_input.affine
parcellation_bin, target_affine=t_input_img.affine
) # type: ignore
# Mask the input data and the parcellation
data = masker.fit_transform(t_input)
data = masker.fit_transform(t_input_img)
parcellation_values = masker.transform(parcellation_img_res)
parcellation_values = np.squeeze(parcellation_values).astype(int)

View file

@ -4,7 +4,7 @@
# License: AGPL
from typing import Any, Dict, Optional
from typing import Any, Dict, Optional, Union
import numpy as np
@ -91,7 +91,7 @@ class ReHoParcels(ReHoBase):
reho_params: Optional[Dict] = None,
agg_method: str = "mean",
agg_method_params: Optional[Dict] = None,
mask: Optional[str] = None,
mask: Union[str, Dict, None] = None,
name: Optional[str] = None,
) -> None:
self.parcellation = parcellation

View file

@ -4,7 +4,7 @@
# License: AGPL
from typing import Any, Dict, Optional
from typing import Any, Dict, Optional, Union
import numpy as np
@ -97,7 +97,7 @@ class ReHoSpheres(ReHoBase):
reho_params: Optional[Dict] = None,
agg_method: str = "mean",
agg_method_params: Optional[Dict] = None,
mask: Optional[str] = None,
mask: Union[str, Dict, None] = None,
name: Optional[str] = None,
) -> None:
self.coords = coords

View file

@ -7,7 +7,7 @@
from typing import Any, Dict, List, Optional, Union
from ..api.decorators import register_marker
from ..data import load_coordinates, load_mask
from ..data import load_coordinates, get_mask
from ..external.nilearn import JuniferNiftiSpheresMasker
from ..stats import get_aggfunc_by_name
from ..utils import logger
@ -56,7 +56,7 @@ class SphereAggregation(BaseMarker):
radius: Optional[float] = None,
method: str = "mean",
method_params: Optional[Dict[str, Any]] = None,
mask: Optional[str] = None,
mask: Union[str, Dict, None] = None,
on: Union[List[str], str, None] = None,
name: Optional[str] = None,
) -> None:
@ -129,7 +129,7 @@ class SphereAggregation(BaseMarker):
* ``columns`` : the column labels for the computed values as a list
"""
t_input = input["data"]
t_input_img = input["data"]
logger.debug(f"Sphere aggregation using {self.method}")
# Get aggregation function
agg_func = get_aggfunc_by_name(
@ -139,7 +139,7 @@ class SphereAggregation(BaseMarker):
mask_img = None
if self.mask is not None:
logger.debug(f"Masking with {self.mask}")
mask_img, _ = load_mask(self.mask)
mask_img = get_mask(mask=self.mask, target_data=input)
# Get seeds and labels
coords, out_labels = load_coordinates(name=self.coords)
masker = JuniferNiftiSpheresMasker(
@ -149,7 +149,7 @@ class SphereAggregation(BaseMarker):
agg_func=agg_func,
)
# Fit and transform the marker on the data
out_values = masker.fit_transform(t_input)
out_values = masker.fit_transform(t_input_img)
# Format the output
out = {"data": out_values, "columns": out_labels}
return out

View file

@ -12,6 +12,7 @@ import pytest
from nilearn import datasets
from nilearn.image import concat_imgs, math_img, new_img_like, resample_to_img
from nilearn.maskers import NiftiLabelsMasker, NiftiMasker
from nilearn.masking import compute_brain_mask
from numpy.testing import assert_array_almost_equal, assert_array_equal
from scipy.stats import trim_mean
@ -240,6 +241,54 @@ def test_ParcelAggregation_3D_mask() -> None:
assert_array_almost_equal(auto, jun_values3d_mean)
def test_ParcelAggregation_3D_mask_computed() -> None:
"""Test ParcelAggregation object on 3D images with computed masks."""
# Get the testing parcellation (for nilearn)
parcellation = datasets.fetch_atlas_schaefer_2018(n_rois=100)
# Get the oasis VBM data
oasis_dataset = datasets.fetch_oasis_vbm(n_subjects=1)
vbm = oasis_dataset.gray_matter_maps[0]
img = nib.load(vbm)
# Get one mask
mask_img = compute_brain_mask(img, threshold=0.2)
# Create NiftiLabelsMasker
nifti_masker = NiftiLabelsMasker(
labels_img=parcellation.maps, mask_img=mask_img
)
auto = nifti_masker.fit_transform(img)
# Get one mask
mask_img = compute_brain_mask(img, threshold=0.5)
# Create NiftiLabelsMasker
nifti_masker = NiftiLabelsMasker(
labels_img=parcellation.maps, mask_img=mask_img
)
auto_bad = nifti_masker.fit_transform(img)
# Use the ParcelAggregation object
marker = ParcelAggregation(
parcellation="Schaefer100x7",
method="mean",
mask={"compute_brain_mask": {"threshold": 0.2}},
name="gmd_schaefer100x7_mean",
on="VBM_GM",
) # Test passing "on" as a keyword argument
input = {"VBM_GM": {"data": img, "meta": {}}}
jun_values3d_mean = marker.fit_transform(input)["VBM_GM"]["data"]
assert jun_values3d_mean.ndim == 2
assert jun_values3d_mean.shape[0] == 1
assert_array_almost_equal(auto, jun_values3d_mean)
with pytest.raises(AssertionError):
assert_array_almost_equal(jun_values3d_mean, auto_bad)
def test_ParcelAggregation_3D_multiple_non_overlapping(tmp_path: Path) -> None:
"""Test ParcelAggregation with multiple non-overlapping parcellations.