[ENH]: Introduce MapsRegistry #458

Merged
synchon merged 20 commits from feat/maps-registry into main 2025-09-08 10:45:11 +00:00
18 changed files with 1153 additions and 12 deletions

View file

@ -13,7 +13,7 @@ dependencies:
- ruamel.yaml>=0.17,<0.19 - ruamel.yaml>=0.17,<0.19
- h5py>=3.10 - h5py>=3.10
- tqdm>=4.66.1,<4.67.0 - tqdm>=4.66.1,<4.67.0
- templateflow>=23.0.0 - templateflow>=23.0.0,<25.0.0
- lapy>=1.0.0,<2.0.0 - lapy>=1.0.0,<2.0.0
- lazy_loader==0.4 - lazy_loader==0.4
- importlib_metadata - importlib_metadata

View file

@ -743,5 +743,39 @@ Available
Planned Planned
fraimondo commented 2025-09-08 06:42:26 +00:00 (Migrated from github.com)

mask -> map

`mask` -> `map`
~~~~~~~ ~~~~~~~
Maps
----
..
Provide a list of the maps that are implemented or planned.
Version added: The junifer version in which the maps was added.
Available
~~~~~~~~~
.. list-table::
:widths: auto
:header-rows: 1
* - Name
- Options
- Keys
- Template Spaces
- Version Added
- Publication
* - Smith
- ``components``, ``dimension``
- | ``Smith_rsn_10``, ``Smith_rsn_20``, ``Smith_rsn_70``,
| ``Smith_bm_10``, ``Smith_bm_20``, ``Smith_bm_70``
- ``MNI152NLin6Asym``
- 0.0.7
- | S.M. Smith, P.T. Fox, K.L. Miller et al.
| Correspondence of the brain's functional architecture during activation and rest
| Proc. Natl. Acad. Sci. U.S.A. 106 (31) 13040-13045 (2009)
| https://doi.org/10.1073/pnas.0905267106
.. ..
helpful site for creating tables: https://rest-sphinx-memo.readthedocs.io/en/latest/ReST.html#tables helpful site for creating tables: https://rest-sphinx-memo.readthedocs.io/en/latest/ReST.html#tables

View file

@ -0,0 +1 @@
:func:`.get_data`, :func:`.load_data`, :func:`.list_data`, :func:`.register_data` and :func:`.deregister_data` now support ``"maps"`` as a valid argument for ``kind`` by `Synchon Mandal`_

View file

@ -0,0 +1 @@
Introduce :class:`.MapsRegistry` to centralise probabilistic atlas (maps) data management by `Synchon Mandal`_

View file

@ -0,0 +1 @@
Constrain ``templateflow`` version to ``>=23.0.0,<25.0.0`` by `Synchon Mandal`_

View file

@ -30,6 +30,7 @@ DataGrabbers, Preprocessors, Markers, etc., following the *junifer* way.
parcellations parcellations
coordinates coordinates
masks masks
maps
plugins plugins
data_registries data_registries
data_types data_types

140
docs/extending/maps.rst Normal file
View file

@ -0,0 +1,140 @@
.. include:: ../links.inc
.. _adding_maps:
Adding Maps
===========
Maps in ``junifer`` are basically probabilistic atlases. They are differentiated
from parcellations for ease of computation and to handle edge cases in a sane manner.
Before you start adding your own maps, check whether ``junifer`` has
the map(s) :ref:`in-built already <builtin>`. Perhaps, what is available
there will suffice to achieve your goals. However, of course ``junifer`` will not
have every map(s) available that you may want to use, and if so, it will
be nice to be able to add it yourself using a format that ``junifer`` understands.
Similarly, you may even be interested in creating your own custom maps
and then adding them to ``junifer``, so you can use ``junifer`` to obtain
different Markers to assess and validate your own maps. So, how can you do
this?
Since both of these use-cases are quite common, and not being able to use your
favourite map(s) is of course quite a buzzkill, ``junifer`` actually
provides the easy-to-use :func:`.register_data` function to do just that.
Let's try to understand the API reference and then use this function to register our
own map(s).
From the API reference, we can see that it has 3 positional arguments:
* ``kind``
* ``name``
* ``space``
as well as one optional keyword argument: ``overwrite``.
As the ``kind`` needs to be ``"maps"``, we can check
``MapsRegistry.register`` for keyword arguments to be passed:
* ``maps_path``
* ``maps_labels``
The ``name`` of the map(s) is up to you and will be the name that
``junifer`` will use to refer to this particular maps. You can think of
this as being similar to a key in a Python dictionary, i.e. a key that is used to
obtain and operate on the actual maps data. This ``name`` must always
be a string. For example, we could call our map(s)
``"my_custom_maps"`` (Note, that in a real-world use case this is
likely not a good name, and you should try to choose a meaningful name that
conveys as much relevant information about your maps as necessary).
The ``maps_path`` must be a ``str`` or ``Path`` object indicating a
path to a valid 4D NIfTI image, which contains floating-point labels indicating the
individual regions-of-interest (ROIs) of your map(s).
We also want to make sure that we can associate each label with
a human readable name (i.e. the name for each ROI). This serves naming the
features that maps-based markers produce in an unambiguous way, such
that a user can easily identify which ROIs were used to produce a specific
feature (multiple ROIs, because some features consist of information from two
or more ROIs, as for example in functional connectivity). Therefore, we provide
junifer with a list of strings, that contains the names for each ROI. In this
list, the label at the i-th position indicates the i-th index in the 4th dimension
of the NIfTI image.
Lastly, we specify the ``space`` that the map(s) is in, for example,
``"MNI152NLin6Asym"`` or ``"native"`` (scanner-native space).
Step 1: Prepare code to register a maps
---------------------------------------
Now we know everything that we need to know to make sure ``junifer`` can use our
own map(s) to compute any maps-based Marker. A simple example could
look like this:
.. code-block:: python
from pathlib import Path
import numpy as np
from junifer.data import register_data
# these are of course just example paths, replace it with your own:
path_to_maps = (
Path.cwd() / "my_custom_maps.nii.gz"
)
path_to_labels = (
Path.cwd() / "my_custom_maps_labels.txt"
)
my_labels = list(np.loadtxt(path_to_labels, dtype=str))
register_data(
kind="maps",
name="my_custom_maps",
maps_path=path_to_maps,
maps_labels=my_labels,
space="MNI152NLin6Asym",
)
We can run this code and it seems to work, however, how can we actually
include the custom map(s) in a ``junifer`` pipeline using a
:ref:`code-less YAML configuration <codeless>`?
Step 2: Add maps registration to the YAML file
----------------------------------------------
In order to use the maps in a ``junifer`` pipeline configured by a YAML
file, we can save the above code in a Python file, say
``registering_my_maps.py``. We can then simply add this file using the
``with`` keyword provided by ``junifer``:
.. code-block:: yaml
with:
- registering_my_maps.py
Afterwards continue configuring the rest of the pipeline in this YAML file, and
you will be able to use this maps using the name you gave the
maps when registering it. For example, we can add a
``MapsAggregation`` Marker to demonstrate how this can be done:
.. code-block:: yaml
markers:
- name: CustomMaps_mean
kind: MapsAggregation
maps: my_custom_maps
method: mean
Now, you can simply use this YAML file to run your pipeline.
.. important::
It's important to keep in mind that if the paths given in
``registering_my_maps.py`` are relative paths, they will be interpreted
by ``junifer`` as relative to the jobs directory (i.e. where ``junifer`` will
create submit files, logs directory and so on). For simplicity, you may just
want to use absolute paths to avoid confusion, yet using relative paths is
likely a better way to make your pipeline directory / repository more portable
and therefore more reproducible for others. Really, once you understand how
these paths are interpreted by ``junifer``, it is quite easy.

View file

@ -3,6 +3,7 @@ __all__ = [
"CoordinatesRegistry", "CoordinatesRegistry",
"DataDispatcher", "DataDispatcher",
"ParcellationRegistry", "ParcellationRegistry",
"MapsRegistry",
"MaskRegistry", "MaskRegistry",
"get_data", "get_data",
"list_data", "list_data",
@ -17,6 +18,7 @@ __all__ = [
from .pipeline_data_registry_base import BasePipelineDataRegistry from .pipeline_data_registry_base import BasePipelineDataRegistry
from .coordinates import CoordinatesRegistry from .coordinates import CoordinatesRegistry
from .parcellations import ParcellationRegistry from .parcellations import ParcellationRegistry
from .maps import MapsRegistry
from .masks import MaskRegistry from .masks import MaskRegistry
from ._dispatch import ( from ._dispatch import (

View file

@ -17,6 +17,7 @@ from numpy.typing import ArrayLike
from ..utils import raise_error from ..utils import raise_error
from .coordinates import CoordinatesRegistry from .coordinates import CoordinatesRegistry
from .maps import MapsRegistry
from .masks import MaskRegistry from .masks import MaskRegistry
from .parcellations import ParcellationRegistry from .parcellations import ParcellationRegistry
from .pipeline_data_registry_base import BasePipelineDataRegistry from .pipeline_data_registry_base import BasePipelineDataRegistry
@ -52,6 +53,7 @@ class DataDispatcher(MutableMapping):
{ {
"coordinates": CoordinatesRegistry, "coordinates": CoordinatesRegistry,
"parcellation": ParcellationRegistry, "parcellation": ParcellationRegistry,
"maps": MapsRegistry,
"mask": MaskRegistry, "mask": MaskRegistry,
} }
) )
@ -118,14 +120,14 @@ def get_data(
extra_input: Optional[dict[str, Any]] = None, extra_input: Optional[dict[str, Any]] = None,
) -> Union[ ) -> Union[
tuple[ArrayLike, list[str]], # coordinates tuple[ArrayLike, list[str]], # coordinates
tuple["Nifti1Image", list[str]], # parcellation tuple["Nifti1Image", list[str]], # parcellation / maps
"Nifti1Image", # mask "Nifti1Image", # mask
]: ]:
"""Get tailored ``kind`` for ``target_data``. """Get tailored ``kind`` for ``target_data``.
Parameters Parameters
---------- ----------
kind : {"coordinates", "parcellation", "mask"} kind : {"coordinates", "parcellation", "mask", "maps"}
Kind of data to fetch and apply. Kind of data to fetch and apply.
names : str or dict or list of str / dict names : str or dict or list of str / dict
The registered name(s) of the data. The registered name(s) of the data.
@ -166,7 +168,7 @@ def list_data(kind: str) -> list[str]:
Parameters Parameters
---------- ----------
kind : {"coordinates", "parcellation", "mask"} kind : {"coordinates", "parcellation", "mask", "maps"}
Kind of data registry to list. Kind of data registry to list.
Returns Returns
@ -195,7 +197,9 @@ def load_data(
**kwargs, **kwargs,
) -> Union[ ) -> Union[
tuple[ArrayLike, list[str], str], # coordinates tuple[ArrayLike, list[str], str], # coordinates
tuple[Optional["Nifti1Image"], list[str], Path, str], # parcellation tuple[
Optional["Nifti1Image"], list[str], Path, str
], # parcellation / maps
tuple[ tuple[
Optional[Union["Nifti1Image", Callable]], Optional[Path], str Optional[Union["Nifti1Image", Callable]], Optional[Path], str
], # mask ], # mask
@ -204,7 +208,7 @@ def load_data(
Parameters Parameters
---------- ----------
kind : {"coordinates", "parcellation", "mask"} kind : {"coordinates", "parcellation", "mask", "maps"}
Kind of data to load. Kind of data to load.
name : str name : str
The registered name of the data. The registered name of the data.
@ -244,7 +248,7 @@ def register_data(
Parameters Parameters
---------- ----------
kind : {"coordinates", "parcellation", "mask"} kind : {"coordinates", "parcellation", "mask", "maps"}
Kind of data to register. Kind of data to register.
name : str name : str
The name to register. The name to register.
@ -277,7 +281,7 @@ def deregister_data(kind: str, name: str) -> None:
Parameters Parameters
---------- ----------
kind : {"coordinates", "parcellation", "mask"} kind : {"coordinates", "parcellation", "mask", "maps"}
Kind of data to register. Kind of data to register.
name : str name : str
The name to de-register. The name to de-register.

View file

@ -0,0 +1,9 @@
"""Maps."""
# Authors: Synchon Mandal <s.mandal@fz-juelich.de>
# License: AGPL
import lazy_loader as lazy
__getattr__, __dir__, __all__ = lazy.attach_stub(__name__, __file__)

View file

@ -0,0 +1,5 @@
__all__ = [
"MapsRegistry",
]
from ._maps import MapsRegistry

View file

@ -0,0 +1,156 @@
"""Provide class for maps space warping via ANTs."""
# Authors: Synchon Mandal <s.mandal@fz-juelich.de>
# License: AGPL
import uuid
from typing import TYPE_CHECKING, Any, Optional
import nibabel as nib
from ...pipeline import WorkDirManager
from ...utils import logger, raise_error, run_ext_cmd
from ..template_spaces import get_template, get_xfm
if TYPE_CHECKING:
from nibabel.nifti1 import Nifti1Image
__all__ = ["ANTsMapsWarper"]
class ANTsMapsWarper:
"""Class for maps space warping via ANTs.
This class uses ANTs ``antsApplyTransforms`` for transformation.
"""
def warp(
self,
maps_name: str,
maps_img: "Nifti1Image",
src: str,
dst: str,
target_data: dict[str, Any],
warp_data: Optional[dict[str, Any]],
) -> "Nifti1Image":
"""Warp ``maps_img`` to correct space.
Parameters
----------
maps_name : str
The name of the maps.
maps_img : nibabel.nifti1.Nifti1Image
The maps image to transform.
src : str
The data type or template space to warp from.
It should be empty string if ``dst="T1w"``.
dst : str
The data type or template space to warp to.
`"T1w"` is the only allowed data type and it uses the resampled T1w
found in ``target_data.reference``. The ``"reference"``
key is added if the :class:`.SpaceWarper` is used or if the
data is provided in native space.
target_data : dict
The corresponding item of the data object to which the maps
will be applied.
warp_data : dict or None
The warp data item of the data object. The value is unused if
``dst!="T1w"``.
Returns
-------
nibabel.nifti1.Nifti1Image
The transformed maps image.
Raises
------
ValueError
If ``warp_data`` is None when ``dst="T1w"``.
"""
# Create element-scoped tempdir so that warped maps is
# available later as nibabel stores file path reference for
# loading on computation
prefix = (
f"ants_maps_warper_{maps_name}"
f"{'' if not src else f'_from_{src}'}_to_{dst}_"
f"{uuid.uuid1()}"
)
element_tempdir = WorkDirManager().get_element_tempdir(
prefix=prefix,
)
# Native space warping
if dst == "native": # pragma: no cover
# Warp data check
if warp_data is None:
raise_error("No `warp_data` provided")
if "reference" not in target_data:
raise_error("No `reference` provided")
if "path" not in target_data["reference"]:
raise_error("No `path` provided in `reference`")
logger.debug("Using ANTs for maps transformation")
# Save existing maps image to a tempfile
prewarp_maps_path = element_tempdir / "prewarp_maps.nii.gz"
nib.save(maps_img, prewarp_maps_path)
# Create a tempfile for warped output
warped_maps_path = element_tempdir / "maps_warped.nii.gz"
# Set antsApplyTransforms command
apply_transforms_cmd = [
"antsApplyTransforms",
"-d 3",
"-e 3",
"-n LanczosWindowedSinc",
f"-i {prewarp_maps_path.resolve()}",
# use resampled reference
f"-r {target_data['reference']['path'].resolve()}",
f"-t {warp_data['path'].resolve()}",
f"-o {warped_maps_path.resolve()}",
]
# Call antsApplyTransforms
run_ext_cmd(name="antsApplyTransforms", cmd=apply_transforms_cmd)
# Template space warping
else:
logger.debug(f"Using ANTs to warp maps from {src} to {dst}")
# Get xfm file
xfm_file_path = get_xfm(src=src, dst=dst)
# Get template space image
template_space_img = get_template(
space=dst,
target_img=maps_img,
extra_input=None,
)
# Save template to a tempfile
template_space_img_path = element_tempdir / f"{dst}_T1w.nii.gz"
nib.save(template_space_img, template_space_img_path)
# Save existing maps image to a tempfile
prewarp_maps_path = element_tempdir / "prewarp_maps.nii.gz"
nib.save(maps_img, prewarp_maps_path)
# Create a tempfile for warped output
warped_maps_path = element_tempdir / "maps_warped.nii.gz"
# Set antsApplyTransforms command
apply_transforms_cmd = [
"antsApplyTransforms",
"-d 3",
"-e 3",
"-n LanczosWindowedSinc",
f"-i {prewarp_maps_path.resolve()}",
f"-r {template_space_img_path.resolve()}",
f"-t {xfm_file_path.resolve()}",
f"-o {warped_maps_path.resolve()}",
]
# Call antsApplyTransforms
run_ext_cmd(name="antsApplyTransforms", cmd=apply_transforms_cmd)
# Load nifti
return nib.load(warped_maps_path)

View file

@ -0,0 +1,85 @@
"""Provide class for maps space warping via FSL FLIRT."""
# Authors: Synchon Mandal <s.mandal@fz-juelich.de>
# License: AGPL
import uuid
from typing import TYPE_CHECKING, Any
import nibabel as nib
from ...pipeline import WorkDirManager
from ...utils import logger, run_ext_cmd
if TYPE_CHECKING:
from nibabel.nifti1 import Nifti1Image
__all__ = ["FSLMapsWarper"]
class FSLMapsWarper:
"""Class for maps space warping via FSL FLIRT.
This class uses FSL FLIRT's ``applywarp`` for transformation.
"""
def warp(
self,
maps_name: str,
maps_img: "Nifti1Image",
target_data: dict[str, Any],
warp_data: dict[str, Any],
) -> "Nifti1Image": # pragma: no cover
"""Warp ``maps_img`` to correct space.
Parameters
----------
maps_name : str
The name of the maps.
maps_img : nibabel.nifti1.Nifti1Image
The maps image to transform.
target_data : dict
The corresponding item of the data object to which the maps
will be applied.
warp_data : dict
The warp data item of the data object.
Returns
-------
nibabel.nifti1.Nifti1Image
The transformed maps image.
"""
logger.debug("Using FSL for maps transformation")
# Create element-scoped tempdir so that warped maps is
# available later as nibabel stores file path reference for
# loading on computation
element_tempdir = WorkDirManager().get_element_tempdir(
prefix=f"fsl_maps_warper_{maps_name}_{uuid.uuid1()}"
)
# Save existing maps image to a tempfile
prewarp_maps_path = element_tempdir / "prewarp_maps.nii.gz"
nib.save(maps_img, prewarp_maps_path)
# Create a tempfile for warped output
warped_maps_path = element_tempdir / "maps_warped.nii.gz"
# Set applywarp command
applywarp_cmd = [
"applywarp",
"--interp=spline",
f"-i {prewarp_maps_path.resolve()}",
# use resampled reference
f"-r {target_data['reference']['path'].resolve()}",
f"-w {warp_data['path'].resolve()}",
f"-o {warped_maps_path.resolve()}",
]
# Call applywarp
run_ext_cmd(name="applywarp", cmd=applywarp_cmd)
# Load nifti
return nib.load(warped_maps_path)

446
junifer/data/maps/_maps.py Normal file
View file

@ -0,0 +1,446 @@
"""Provide a class for centralized maps data registry."""
# Authors: Synchon Mandal <s.mandal@fz-juelich.de>
# License: AGPL
from pathlib import Path
from typing import TYPE_CHECKING, Any, Optional, Union
import nibabel as nib
import nilearn.image as nimg
import numpy as np
from junifer_data import get
from ...utils import logger, raise_error
from ..pipeline_data_registry_base import BasePipelineDataRegistry
from ..utils import (
JUNIFER_DATA_PARAMS,
closest_resolution,
get_dataset_path,
get_native_warper,
)
from ._ants_maps_warper import ANTsMapsWarper
from ._fsl_maps_warper import FSLMapsWarper
if TYPE_CHECKING:
from nibabel.nifti1 import Nifti1Image
__all__ = ["MapsRegistry"]
class MapsRegistry(BasePipelineDataRegistry):
"""Class for maps data registry.
This class is a singleton and is used for managing available maps
data in a centralized manner.
"""
def __init__(self) -> None:
"""Initialize the class."""
super().__init__()
# Each entry in registry is a dictionary that must contain at least
# the following keys:
# * 'family': the maps' family name (e.g., 'Smith')
# * 'space': the maps' space (e.g., 'MNI')
# and can also have optional key(s):
# * 'valid_resolutions': a list of valid resolutions for the
# maps (e.g., [1, 2])
# The built-in maps are files that are shipped with the
# junifer-data dataset.
# Make built-in and external dictionaries for validation later
self._builtin = {}
self._external = {}
# Add Smith
for comp in ["rsn", "bm"]:
for dim in [10, 20, 70]:
self._builtin.update(
{
f"Smith_{comp}_{dim}": {
"family": "Smith2009",
"components": comp,
"dimension": dim,
"space": "MNI152NLin6Asym",
}
}
)
# Update registry with built-in ones
self._registry.update(self._builtin)
def register(
self,
name: str,
maps_path: Union[str, Path],
maps_labels: list[str],
space: str,
overwrite: bool = False,
) -> None:
"""Register a custom user map(s).
Parameters
----------
name : str
The name of the map(s).
maps_path : str or pathlib.Path
The path to the map(s) file.
maps_labels : list of str
The list of labels for the map(s).
space : str
The template space of the map(s), e.g., "MNI152NLin6Asym".
overwrite : bool, optional
If True, overwrite an existing maps with the same name.
Does not apply to built-in maps (default False).
Raises
------
ValueError
If the map(s) ``name`` is a built-in map(s) or
if the map(s) ``name`` is already registered and
``overwrite=False``.
"""
# Check for attempt of overwriting built-in maps
if name in self._builtin:
raise_error(
f"Map(s): {name} already registered as built-in map(s)."
)
# Check for attempt of overwriting external maps
if name in self._external:
if overwrite:
logger.info(f"Overwriting map(s): {name}")
else:
raise_error(
f"Map(s): {name} already registered. Set "
"`overwrite=True` to update its value."
)
# Convert str to Path
if not isinstance(maps_path, Path):
maps_path = Path(maps_path)
# Registration
logger.info(f"Registering map(s): {name}")
# Add user maps info
self._external[name] = {
"path": maps_path,
"labels": maps_labels,
"family": "CustomUserMaps",
"space": space,
}
# Update registry
self._registry[name] = {
"path": maps_path,
"labels": maps_labels,
"family": "CustomUserMaps",
"space": space,
}
def deregister(self, name: str) -> None:
"""De-register a custom user map(s).
Parameters
----------
name : str
The name of the map(s).
"""
logger.info(f"De-registering map(s): {name}")
# Remove maps info
_ = self._external.pop(name)
# Update registry
_ = self._registry.pop(name)
def load(
self,
name: str,
target_space: str,
resolution: Optional[float] = None,
path_only: bool = False,
) -> tuple[Optional["Nifti1Image"], list[str], Path, str]:
"""Load map(s) and labels.
Parameters
----------
name : str
The name of the map(s).
target_space : str
The desired space of the map(s).
resolution : float, optional
The desired resolution of the map(s) to load. If it is not
available, the closest resolution will be loaded. Preferably, use a
resolution higher than the desired one. By default, will load the
highest one (default None).
path_only : bool, optional
If True, the map(s) image will not be loaded (default False).
Returns
-------
Nifti1Image or None
Loaded map(s) image.
list of str
Map(s) labels.
pathlib.Path
File path to the map(s) image.
str
The space of the map(s).
Raises
------
ValueError
If ``name`` is invalid or
if the map(s) family is invalid or
if the map(s) values and labels
don't have equal dimension or if the value range is invalid.
"""
# Check for valid maps name
if name not in self._registry:
raise_error(
f"Map(s): {name} not found. Valid options are: {self.list}"
)
# Copy maps definition to avoid edits in original object
maps_def = self._registry[name].copy()
t_family = maps_def.pop("family")
space = maps_def.pop("space")
# Check and get highest resolution
if space != target_space:
logger.info(
f"Map(s) will be warped from {space} to {target_space} "
"using highest resolution"
)
resolution = None
# Check if the maps family is custom or built-in
if t_family == "CustomUserMaps":
maps_fname = maps_def["path"]
maps_labels = maps_def["labels"]
elif t_family in [
"Smith2009",
]:
# Load maps and labels
if t_family == "Smith2009":
maps_fname, maps_labels = _retrieve_smith(
resolution=resolution,
**maps_def,
)
else: # pragma: no cover
raise_error(f"Unknown map(s) family: {t_family}")
# Load maps image and values
logger.info(f"Loading map(s): {maps_fname.absolute()!s}")
maps_img = None
if not path_only:
# Load image via nibabel
maps_img = nib.load(maps_fname)
# Get regions
maps_regions = maps_img.get_fdata().shape[-1]
# Check for dimension
if maps_regions != len(maps_labels):
raise_error(
f"Map(s) {name} has {maps_regions} "
f"regions but {len(maps_labels)} labels."
)
return maps_img, maps_labels, maps_fname, space
def get(
self,
maps: str,
target_data: dict[str, Any],
extra_input: Optional[dict[str, Any]] = None,
) -> tuple["Nifti1Image", list[str]]:
"""Get map(s), tailored for the target image.
Parameters
----------
maps : str
The name of the map(s).
target_data : dict
The corresponding item of the data object to which the map(s)
will be applied.
extra_input : dict, optional
The other fields in the data object. Useful for accessing other
data kinds that needs to be used in the computation of
map(s) (default None).
Returns
-------
Nifti1Image
The map(s) image.
list of str
Map(s) labels.
Raises
------
ValueError
If ``extra_input`` is None when ``target_data``'s space is native.
"""
# Check pre-requirements for space manipulation
target_space = target_data["space"]
logger.debug(f"Getting {maps} in {target_space} space.")
# Extra data type requirement check if target space is native
if target_space == "native": # pragma: no cover
# Check for extra inputs
if extra_input is None:
raise_error(
"No extra input provided, requires `Warp` and `T1w` "
"data types in particular for transformation to "
f"{target_data['space']} space for further computation."
)
# Get native space warper spec
warper_spec = get_native_warper(
target_data=target_data,
other_data=extra_input,
)
# Set target standard space to warp file space source
target_std_space = warper_spec["src"]
logger.debug(
f"Target space is native. Will warp from {target_std_space}"
)
else:
# Set target standard space to target space
target_std_space = target_space
# 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])
# Load maps
logger.debug(f"Loading map(s) {maps}")
img, labels, _, space = self.load(
name=maps,
resolution=resolution,
target_space=target_space,
)
# Convert maps spaces if required;
# cannot be "native" due to earlier check
if space != target_std_space:
logger.debug(
f"Warping {maps} to {target_std_space} space using ANTs."
)
raw_img = ANTsMapsWarper().warp(
maps_name=maps,
maps_img=img,
src=space,
dst=target_std_space,
target_data=target_data,
warp_data=None,
)
# Remove extra dimension added by ANTs
img = nimg.math_img("np.squeeze(img)", img=raw_img)
if target_space != "native":
# No warping is going to happen, just resampling, because
# we are in the correct space
logger.debug(f"Resampling {maps} to target image.")
# Resample maps to target image
img = nimg.resample_to_img(
source_img=img,
target_img=target_img,
interpolation="continuous",
copy=True,
)
else: # pragma: no cover
# Warp maps if target space is native as either
# the image is in the right non-native space or it's
# warped from one non-native space to another non-native space
logger.debug(
"Warping map(s) to native space using "
f"{warper_spec['warper']}."
)
# extra_input check done earlier and warper_spec exists
if warper_spec["warper"] == "fsl":
img = FSLMapsWarper().warp(
maps_name="native",
maps_img=img,
target_data=target_data,
warp_data=warper_spec,
)
elif warper_spec["warper"] == "ants":
img = ANTsMapsWarper().warp(
maps_name="native",
maps_img=img,
src="",
dst="native",
target_data=target_data,
warp_data=warper_spec,
)
return img, labels
def _retrieve_smith(
resolution: Optional[float] = None,
components: Optional[str] = None,
dimension: Optional[int] = None,
) -> tuple[Path, list[str]]:
"""Retrieve Smith maps.
Parameters
----------
resolution : 2.0, optional
The desired resolution of the maps to load. If it is not
available, the closest resolution will be loaded. Preferably, use a
resolution higher than the desired one. By default, will load the
highest one (default None). Available resolution for these
maps are 2mm.
components : {"rsn", "bm"}, optional
The components to load. "rsn" loads the resting-fMRI components and
"bm" loads the BrainMap components (default None).
dimension : {10, 20, 70}, optional
The number of dimensions to load (default None).
Returns
-------
pathlib.Path
File path to the maps image.
list of str
Maps labels.
Raises
------
ValueError
If invalid value is provided for ``components`` or ``dimension``.
"""
logger.info("Maps parameters:")
logger.info(f"\tresolution: {resolution}")
logger.info(f"\tcomponents: {components}")
logger.info(f"\tdimension: {dimension}")
# Check resolution
_valid_resolutions = [2.0]
resolution = closest_resolution(resolution, _valid_resolutions)
# Check components value
_valid_components = ["rsn", "bm"]
if components not in _valid_components:
raise_error(
f"The parameter `components` ({components}) needs to be one of "
f"the following: {_valid_components}"
)
# Check dimension value
_valid_dimension = [10, 20, 70]
if dimension not in _valid_dimension:
raise_error(
f"The parameter `dimension` ({dimension}) needs to be one of the "
f"following: {_valid_dimension}"
)
# Fetch file path
maps_img_path = get(
file_path=Path(
f"parcellations/Smith2009/{components}{dimension}.nii.gz"
),
dataset_path=get_dataset_path(),
**JUNIFER_DATA_PARAMS,
)
return maps_img_path, [f"Map_{i}" for i in range(dimension)]

View file

@ -0,0 +1,255 @@
"""Provide tests for maps."""
# Authors: Synchon Mandal <s.mandal@fz-juelich.de>
# License: AGPL
import pytest
from numpy.testing import assert_array_equal
from junifer.data import (
deregister_data,
get_data,
list_data,
load_data,
register_data,
)
from junifer.data.maps._maps import _retrieve_smith
from junifer.datagrabber import PatternDataladDataGrabber
from junifer.datareader import DefaultDataReader
from junifer.pipeline.utils import _check_ants
from junifer.testing.datagrabbers import PartlyCloudyTestingDataGrabber
def test_register_built_in_check() -> None:
"""Test maps registration check for built-in maps."""
with pytest.raises(ValueError, match=r"built-in"):
register_data(
kind="maps",
name="Smith_rsn_10",
maps_path="testmaps.nii.gz",
maps_labels=["1", "2"],
space="MNI",
)
def test_list_incorrect() -> None:
"""Test incorrect information check for list mapss."""
assert "testmaps" not in list_data(kind="maps")
def test_register_overwrite() -> None:
"""Test maps registration check for overwriting."""
register_data(
kind="maps",
name="testmaps",
maps_path="testmaps.nii.gz",
maps_labels=["1", "2"],
space="MNI152NLin6Sym",
)
with pytest.raises(ValueError, match=r"already registered"):
register_data(
kind="maps",
name="testmaps",
maps_path="testmaps.nii.gz",
maps_labels=["1", "2"],
space="MNI152NLin6Sym",
overwrite=False,
)
register_data(
kind="maps",
name="testmaps",
maps_path="testmaps.nii.gz",
maps_labels=["1", "2"],
space="MNI152NLin6Sym",
overwrite=True,
)
assert (
load_data(
kind="maps",
name="testmaps",
target_space="MNI152NLin6Sym",
path_only=True,
)[2].name
== "testmaps.nii.gz"
)
def test_register_valid_input() -> None:
"""Test maps registration check for valid input."""
maps, labels, maps_path, _ = load_data(
kind="maps",
name="Smith_rsn_10",
target_space="MNI152NLin6Asym",
)
assert maps is not None
# Test wrong number of labels
register_data(
kind="maps",
name="WrongLabels",
maps_path=maps_path,
maps_labels=labels[:5],
space="MNI152NLin6Asym",
)
with pytest.raises(ValueError, match=r"has 10 regions but 5"):
load_data(
kind="maps",
name="WrongLabels",
target_space="MNI152NLin6Asym",
)
def test_list() -> None:
"""Test listing of available coordinates."""
assert {"Smith_rsn_10", "Smith_bm_70"}.issubset(
set(list_data(kind="maps"))
)
def test_load_nonexisting() -> None:
"""Test loading maps that not exist."""
with pytest.raises(ValueError, match=r"not found"):
load_data(kind="maps", name="nomaps", target_space="MNI152NLin6Sym")
def test_get() -> None:
"""Test tailored maps fetch."""
with PatternDataladDataGrabber(
uri="https://github.com/OpenNeuroDatasets/ds005226.git",
types=["BOLD"],
patterns={
"BOLD": {
"pattern": (
"derivatives/pre-processed_data/space-MNI/{subject}/"
"{subject-padded}_task-{task}_run-{run}_space-MNI152NLin6Asym"
"_res-2_desc-preproc_bold.nii.gz"
),
"space": "MNI152NLin6Asym",
},
},
replacements=["subject", "subject-padded", "task", "run"],
) as dg:
element = dg[("sub-01", "sub-001", "rest", "1")]
element_data = DefaultDataReader().fit_transform(element)
bold = element_data["BOLD"]
bold_img = bold["data"]
# Get tailored coordinates
tailored_maps, tailored_labels = get_data(
kind="maps", names="Smith_rsn_10", target_data=bold
)
# Check shape with original element data
assert tailored_maps.shape[:3] == bold_img.shape[:3]
# Get raw maps
raw_maps, raw_labels, _, _ = load_data(
kind="maps", name="Smith_rsn_10", target_space="MNI152NLin6Asym"
)
# Tailored and raw shape should be same
assert tailored_maps.shape[:3] == raw_maps.shape[:3]
assert tailored_labels == raw_labels
@pytest.mark.skipif(
_check_ants() is False, reason="requires ANTs to be in PATH"
)
def test_get_different_space() -> None:
"""Test tailored maps fetch in different space."""
with PartlyCloudyTestingDataGrabber() as dg:
element = dg["sub-01"]
element_data = DefaultDataReader().fit_transform(element)
bold = element_data["BOLD"]
bold_img = bold["data"]
# Get tailored coordinates
tailored_maps, tailored_labels = get_data(
kind="maps", names="Smith_rsn_10", target_data=bold
)
# Check shape with original element data
assert tailored_maps.shape[:3] == bold_img.shape[:3]
# Get raw maps
raw_maps, raw_labels, _, _ = load_data(
kind="maps",
name="Smith_rsn_10",
target_space="MNI152NLin2009cAsym",
)
# Tailored and raw should not be same
assert tailored_maps.shape[:3] != raw_maps.shape[:3]
assert tailored_labels == raw_labels
def test_deregister() -> None:
"""Test maps deregistration."""
deregister_data(kind="maps", name="testmaps")
assert "testmaps" not in list_data(kind="maps")
@pytest.mark.parametrize(
"resolution, components, dimension",
[
(2.0, "rsn", 10),
(2.0, "rsn", 20),
(2.0, "rsn", 70),
(2.0, "bm", 10),
(2.0, "bm", 20),
(2.0, "bm", 70),
],
)
def test_smith(
resolution: float,
components: str,
dimension: int,
) -> None:
"""Test Smith maps.
Parameters
----------
resolution : float
The parametrized resolution values.
components : str
The parametrized components values.
dimension : int
The parametrized dimension values.
"""
maps = list_data(kind="maps")
maps_name = f"Smith_{components}_{dimension}"
assert maps_name in maps
maps_file = f"{components}{dimension}.nii.gz"
# Load maps
img, label, img_path, space = load_data(
kind="maps",
name=maps_name,
target_space="MNI152NLin6Asym",
resolution=resolution,
)
assert img is not None
assert img_path.name == maps_file
assert space == "MNI152NLin6Asym"
assert len(label) == dimension
assert_array_equal(
img.header["pixdim"][1:4],
3 * [2.0],
)
def test_retrieve_smith_incorrect_components() -> None:
"""Test retrieve Smith with incorrect components."""
with pytest.raises(ValueError, match="The parameter `components`"):
_retrieve_smith(
components="abc",
dimension=10,
)
def test_retrieve_smith_incorrect_dimension() -> None:
"""Test retrieve Smith with incorrect dimension."""
with pytest.raises(ValueError, match="The parameter `dimension`"):
_retrieve_smith(
components="rsn",
dimension=100,
)

View file

@ -231,8 +231,9 @@ class MaskRegistry(BasePipelineDataRegistry):
# * 'family': the mask's family name # * 'family': the mask's family name
# (e.g., 'Vickery-Patil', 'Callable') # (e.g., 'Vickery-Patil', 'Callable')
# * 'space': the mask's space (e.g., 'MNI', 'inherit') # * 'space': the mask's space (e.g., 'MNI', 'inherit')
# The built-in masks are files that are shipped with the package in the # The built-in masks are files that are shipped either with the
# data/masks directory. The user can also register their own masks. # junifer-data dataset or computed on-demand. The user can also
# register their own masks.
# Callable masks should be functions that take at least one parameter: # Callable masks should be functions that take at least one parameter:
# * `target_img`: the image to which the mask will be applied. # * `target_img`: the image to which the mask will be applied.
# and should be included in the registry as a value to a key: `func`. # and should be included in the registry as a value to a key: `func`.

View file

@ -55,7 +55,7 @@ class ParcellationRegistry(BasePipelineDataRegistry):
# and can also have optional key(s): # and can also have optional key(s):
# * 'valid_resolutions': a list of valid resolutions for the # * 'valid_resolutions': a list of valid resolutions for the
# parcellation (e.g., [1, 2]) # parcellation (e.g., [1, 2])
# The built-in coordinates are files that are shipped with the # The built-in parcellations are files that are shipped with the
# junifer-data dataset. # junifer-data dataset.
# Make built-in and external dictionaries for validation later # Make built-in and external dictionaries for validation later
self._builtin = {} self._builtin = {}

View file

@ -47,7 +47,7 @@ dependencies = [
"ruamel.yaml>=0.17,<0.19", "ruamel.yaml>=0.17,<0.19",
"h5py>=3.10", "h5py>=3.10",
"tqdm>=4.66.1,<4.67.0", "tqdm>=4.66.1,<4.67.0",
"templateflow>=23.0.0", "templateflow>=23.0.0,<25.0.0",
"lapy>=1.0.0,<2.0.0", "lapy>=1.0.0,<2.0.0",
"lazy_loader==0.4", "lazy_loader==0.4",
"importlib_metadata; python_version<'3.9'", "importlib_metadata; python_version<'3.9'",