[ENH]: Add support for BrainPrint #344
16 changed files with 741 additions and 11 deletions
3
.gitmodules
vendored
3
.gitmodules
vendored
|
|
@ -1,3 +1,6 @@
|
||||||
[submodule "junifer/external/h5io"]
|
[submodule "junifer/external/h5io"]
|
||||||
path = junifer/external/h5io
|
path = junifer/external/h5io
|
||||||
url = https://github.com/juaml/h5io
|
url = https://github.com/juaml/h5io
|
||||||
|
[submodule "junifer/external/BrainPrint"]
|
||||||
|
path = junifer/external/BrainPrint
|
||||||
|
url = https://github.com/juaml/BrainPrint.git
|
||||||
|
|
|
||||||
1
docs/changes/newsfragments/344.feature
Normal file
1
docs/changes/newsfragments/344.feature
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Add support for `BrainPrint <https://github.com/Deep-MI/BrainPrint?tab=readme-ov-file>`_ marker by `Synchon Mandal`_
|
||||||
|
|
@ -4,3 +4,4 @@ chang
|
||||||
sepulcre
|
sepulcre
|
||||||
arange
|
arange
|
||||||
sinc
|
sinc
|
||||||
|
whit
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ def test_get_dependency_information_short() -> None:
|
||||||
"httpx",
|
"httpx",
|
||||||
"tqdm",
|
"tqdm",
|
||||||
"templateflow",
|
"templateflow",
|
||||||
|
"lapy",
|
||||||
"looseversion",
|
"looseversion",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -74,6 +75,7 @@ def test_get_dependency_information_long() -> None:
|
||||||
"httpx",
|
"httpx",
|
||||||
"tqdm",
|
"tqdm",
|
||||||
"templateflow",
|
"templateflow",
|
||||||
|
"lapy",
|
||||||
]
|
]
|
||||||
for key in dependency_list:
|
for key in dependency_list:
|
||||||
assert key in dependency_information_keys
|
assert key in dependency_information_keys
|
||||||
|
|
|
||||||
|
|
@ -350,7 +350,7 @@ def test_get_mask_errors() -> None:
|
||||||
with pytest.raises(ValueError, match=r"callable params"):
|
with pytest.raises(ValueError, match=r"callable params"):
|
||||||
get_mask(masks={"GM_prob0.2": {"param": 1}}, target_data=vbm_gm)
|
get_mask(masks={"GM_prob0.2": {"param": 1}}, target_data=vbm_gm)
|
||||||
|
|
||||||
# Pass only parametesr to the intersection function
|
# Pass only parameters to the intersection function
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
ValueError, match=r" At least one mask is required."
|
ValueError, match=r" At least one mask is required."
|
||||||
):
|
):
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
# License: AGPL
|
# License: AGPL
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from copy import deepcopy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Tuple, Union
|
from typing import Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
|
|
@ -364,7 +365,7 @@ class PatternDataGrabber(BaseDataGrabber):
|
||||||
# Data type dictionary
|
# Data type dictionary
|
||||||
t_pattern = self.patterns[t_type]
|
t_pattern = self.patterns[t_type]
|
||||||
# Copy data type dictionary in output
|
# Copy data type dictionary in output
|
||||||
out[t_type] = t_pattern.copy()
|
out[t_type] = deepcopy(t_pattern)
|
||||||
# Iterate to check for nested "types" like mask
|
# Iterate to check for nested "types" like mask
|
||||||
for k, v in t_pattern.items():
|
for k, v in t_pattern.items():
|
||||||
# Resolve pattern for base data type
|
# Resolve pattern for base data type
|
||||||
|
|
|
||||||
1
junifer/external/BrainPrint
vendored
Submodule
1
junifer/external/BrainPrint
vendored
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit adf2b5b5e3289126e3d66fcbd6cc8575ce5a277c
|
||||||
|
|
@ -23,3 +23,4 @@ from .temporal_snr import (
|
||||||
TemporalSNRParcels,
|
TemporalSNRParcels,
|
||||||
TemporalSNRSpheres,
|
TemporalSNRSpheres,
|
||||||
)
|
)
|
||||||
|
from .brainprint import BrainPrint
|
||||||
|
|
|
||||||
662
junifer/markers/brainprint.py
Normal file
662
junifer/markers/brainprint.py
Normal file
|
|
@ -0,0 +1,662 @@
|
||||||
|
"""Provide class for BrainPrint."""
|
||||||
|
|
||||||
|
# Authors: Synchon Mandal <s.mandal@fz-juelich.de>
|
||||||
|
# License: AGPL
|
||||||
|
|
||||||
|
try:
|
||||||
|
from importlib.metadata import packages_distributions
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
from importlib_metadata import packages_distributions
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from copy import deepcopy
|
||||||
|
from importlib.util import find_spec
|
||||||
|
from itertools import chain
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import (
|
||||||
|
TYPE_CHECKING,
|
||||||
|
Any,
|
||||||
|
ClassVar,
|
||||||
|
Dict,
|
||||||
|
List,
|
||||||
|
Optional,
|
||||||
|
Set,
|
||||||
|
Union,
|
||||||
|
)
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import numpy.typing as npt
|
||||||
|
|
||||||
|
from ..api.decorators import register_marker
|
||||||
|
from ..external.BrainPrint.brainprint.brainprint import (
|
||||||
|
compute_asymmetry,
|
||||||
|
compute_brainprint,
|
||||||
|
)
|
||||||
|
from ..external.BrainPrint.brainprint.surfaces import surf_to_vtk
|
||||||
|
from ..pipeline import WorkDirManager
|
||||||
|
from ..pipeline.utils import check_ext_dependencies
|
||||||
|
from ..utils import logger, raise_error, run_ext_cmd
|
||||||
|
from .base import BaseMarker
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from junifer.storage import BaseFeatureStorage
|
||||||
|
|
||||||
|
|
||||||
|
@register_marker
|
||||||
|
class BrainPrint(BaseMarker):
|
||||||
|
"""Class for BrainPrint.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
num : positive int, optional
|
||||||
|
Number of eigenvalues to compute (default 50).
|
||||||
|
skip_cortex : bool, optional
|
||||||
|
Whether to skip cortical surface or not (default False).
|
||||||
|
keep_eigenvectors : bool, optional
|
||||||
|
Whether to also return eigenvectors or not (default False).
|
||||||
|
norm : str, optional
|
||||||
|
Eigenvalues normalization method (default "none").
|
||||||
|
reweight : bool, optional
|
||||||
|
Whether to reweight eigenvalues or not (default False).
|
||||||
|
asymmetry : bool, optional
|
||||||
|
Whether to calculate asymmetry between lateral structures
|
||||||
|
(default False).
|
||||||
|
asymmetry_distance : {"euc"}, optional
|
||||||
|
Distance measurement to use if ``asymmetry=True``:
|
||||||
|
|
||||||
|
* ``"euc"`` : Euclidean
|
||||||
|
|
||||||
|
(default "euc").
|
||||||
|
use_cholmod : bool, optional
|
||||||
|
If True, attempts to use the Cholesky decomposition for improved
|
||||||
|
execution speed. Requires the ``scikit-sparse`` library. If it cannot
|
||||||
|
be found, an error will be thrown. If False, will use slower LU
|
||||||
|
decomposition (default False).
|
||||||
|
name : str, optional
|
||||||
|
The name of the marker. If None, will use the class name (default
|
||||||
|
None).
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
_EXT_DEPENDENCIES: ClassVar[List[Dict[str, Union[str, List[str]]]]] = [
|
||||||
|
{
|
||||||
|
"name": "freesurfer",
|
||||||
|
"commands": [
|
||||||
|
"mri_binarize",
|
||||||
|
"mri_pretess",
|
||||||
|
"mri_mc",
|
||||||
|
"mris_convert",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
_DEPENDENCIES: ClassVar[Set[str]] = {"lapy", "numpy"}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
num: int = 50,
|
||||||
|
skip_cortex=False,
|
||||||
|
keep_eigenvectors: bool = False,
|
||||||
|
norm: str = "none",
|
||||||
|
reweight: bool = False,
|
||||||
|
asymmetry: bool = False,
|
||||||
|
asymmetry_distance: str = "euc",
|
||||||
|
use_cholmod: bool = False,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
self.num = num
|
||||||
|
self.skip_cortex = skip_cortex
|
||||||
|
self.keep_eigenvectors = keep_eigenvectors
|
||||||
|
self.norm = norm
|
||||||
|
self.reweight = reweight
|
||||||
|
self.asymmetry = asymmetry
|
||||||
|
self.asymmetry_distance = asymmetry_distance
|
||||||
|
self.use_cholmod = use_cholmod
|
||||||
|
super().__init__(name=name, on="FreeSurfer")
|
||||||
|
|
||||||
|
def get_valid_inputs(self) -> List[str]:
|
||||||
|
"""Get valid data types for input.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
list of str
|
||||||
|
The list of data types that can be used as input for this marker.
|
||||||
|
|
||||||
|
"""
|
||||||
|
return ["FreeSurfer"]
|
||||||
|
|
||||||
|
# TODO: kept for making this class concrete; should be removed later
|
||||||
|
def get_output_type(self, input_type: str) -> str:
|
||||||
|
"""Get output type.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
input_type : str
|
||||||
|
The data type input to the marker.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
str
|
||||||
|
The storage type output by the marker.
|
||||||
|
|
||||||
|
"""
|
||||||
|
return "vector"
|
||||||
|
|
||||||
|
# TODO: overridden to allow multiple outputs from single data type; should
|
||||||
|
# be removed later
|
||||||
|
def validate(self, input: List[str]) -> List[str]:
|
||||||
|
"""Validate the the pipeline step.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
input : list of str
|
||||||
|
The input to the pipeline step.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
list of str
|
||||||
|
The output of the pipeline step.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _check_dependencies(obj) -> None:
|
||||||
|
"""Check obj._DEPENDENCIES.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
obj : object
|
||||||
|
Object to check _DEPENDENCIES of.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
ImportError
|
||||||
|
If the pipeline step object is missing dependencies required
|
||||||
|
for its working.
|
||||||
|
|
||||||
|
"""
|
||||||
|
# Check if _DEPENDENCIES attribute is found;
|
||||||
|
# (markers and preprocessors will have them but not datareaders
|
||||||
|
# as of now)
|
||||||
|
dependencies_not_found = []
|
||||||
|
if hasattr(obj, "_DEPENDENCIES"):
|
||||||
|
# Check if dependencies are importable
|
||||||
|
for dependency in obj._DEPENDENCIES:
|
||||||
|
# First perform an easy check
|
||||||
|
if find_spec(dependency) is None:
|
||||||
|
# Then check mapped names
|
||||||
|
if dependency not in list(
|
||||||
|
chain.from_iterable(
|
||||||
|
packages_distributions().values()
|
||||||
|
)
|
||||||
|
):
|
||||||
|
dependencies_not_found.append(dependency)
|
||||||
|
# Raise error if any dependency is not found
|
||||||
|
if dependencies_not_found:
|
||||||
|
raise_error(
|
||||||
|
msg=(
|
||||||
|
f"{dependencies_not_found} are not installed but are "
|
||||||
|
f"required for using {obj.__class__.__name__}."
|
||||||
|
),
|
||||||
|
klass=ImportError,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _check_ext_dependencies(obj) -> None:
|
||||||
|
"""Check obj._EXT_DEPENDENCIES.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
obj : object
|
||||||
|
Object to check _EXT_DEPENDENCIES of.
|
||||||
|
|
||||||
|
"""
|
||||||
|
# Check if _EXT_DEPENDENCIES attribute is found;
|
||||||
|
# (some markers and preprocessors might have them)
|
||||||
|
if hasattr(obj, "_EXT_DEPENDENCIES"):
|
||||||
|
for dependency in obj._EXT_DEPENDENCIES:
|
||||||
|
check_ext_dependencies(**dependency)
|
||||||
|
|
||||||
|
# Check dependencies
|
||||||
|
_check_dependencies(self)
|
||||||
|
# Check external dependencies
|
||||||
|
# _check_ext_dependencies(self)
|
||||||
|
# Validate input
|
||||||
|
_ = self.validate_input(input=input)
|
||||||
|
# Validate output type
|
||||||
|
outputs = ["scalar_table", "vector"]
|
||||||
|
return outputs
|
||||||
|
|
||||||
|
def _create_aseg_surface(
|
||||||
|
self,
|
||||||
|
aseg_path: Path,
|
||||||
|
norm_path: Path,
|
||||||
|
indices: List,
|
||||||
|
) -> Path:
|
||||||
|
"""Generate a surface from the aseg and label files.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
aseg_path : pathlib.Path
|
||||||
|
The FreeSurfer aseg path.
|
||||||
|
norm_path : pathlib.Path
|
||||||
|
The FreeSurfer norm path.
|
||||||
|
indices : list
|
||||||
|
List of label indices to include in the surface generation.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
pathlib.Path
|
||||||
|
Path to the generated surface in VTK format.
|
||||||
|
|
||||||
|
"""
|
||||||
|
tempfile_prefix = f"aseg.{uuid.uuid4()}"
|
||||||
|
|
||||||
|
# Set mri_binarize command
|
||||||
|
mri_binarize_output_path = self._tempdir / f"{tempfile_prefix}.mgz"
|
||||||
|
mri_binarize_cmd = [
|
||||||
|
"mri_binarize",
|
||||||
|
f"--i {aseg_path.resolve()}",
|
||||||
|
f"--match {''.join(indices)}",
|
||||||
|
f"--o {mri_binarize_output_path.resolve()}",
|
||||||
|
]
|
||||||
|
# Call mri_binarize command
|
||||||
|
run_ext_cmd(name="mri_binarize", cmd=mri_binarize_cmd)
|
||||||
|
|
||||||
|
label_value = "1"
|
||||||
|
# Fix label (pretess)
|
||||||
|
# Set mri_pretess command
|
||||||
|
mri_pretess_cmd = [
|
||||||
|
"mri_pretess",
|
||||||
|
f"{mri_binarize_output_path.resolve()}",
|
||||||
|
f"{label_value}",
|
||||||
|
f"{norm_path.resolve()}",
|
||||||
|
f"{mri_binarize_output_path.resolve()}",
|
||||||
|
]
|
||||||
|
# Call mri_pretess command
|
||||||
|
run_ext_cmd(name="mri_pretess", cmd=mri_pretess_cmd)
|
||||||
|
|
||||||
|
# Run marching cube to extract surface
|
||||||
|
# Set mri_mc command
|
||||||
|
mri_mc_output_path = self._tempdir / f"{tempfile_prefix}.surf"
|
||||||
|
mri_mc_cmd = [
|
||||||
|
"mri_mc",
|
||||||
|
f"{mri_binarize_output_path.resolve()}",
|
||||||
|
f"{label_value}",
|
||||||
|
f"{mri_mc_output_path.resolve()}",
|
||||||
|
]
|
||||||
|
# Run mri_mc command
|
||||||
|
run_ext_cmd(name="mri_mc", cmd=mri_mc_cmd)
|
||||||
|
|
||||||
|
# Convert to vtk
|
||||||
|
# Set mris_convert command
|
||||||
|
surface_path = (
|
||||||
|
self._element_tempdir / f"aseg.final.{'_'.join(indices)}.vtk"
|
||||||
|
)
|
||||||
|
mris_convert_cmd = [
|
||||||
|
"mris_convert",
|
||||||
|
f"{mri_mc_output_path.resolve()}",
|
||||||
|
f"{surface_path.resolve()}",
|
||||||
|
]
|
||||||
|
# Run mris_convert command
|
||||||
|
run_ext_cmd(name="mris_convert", cmd=mris_convert_cmd)
|
||||||
|
|
||||||
|
return surface_path
|
||||||
|
|
||||||
|
def _create_aseg_surfaces(
|
||||||
|
self,
|
||||||
|
aseg_path: Path,
|
||||||
|
norm_path: Path,
|
||||||
|
) -> Dict[str, Path]:
|
||||||
|
"""Create surfaces from FreeSurfer aseg labels.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
aseg_path : pathlib.Path
|
||||||
|
The FreeSurfer aseg path.
|
||||||
|
norm_path : pathlib.Path
|
||||||
|
The FreeSurfer norm path.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
dict
|
||||||
|
Dictionary of label names mapped to corresponding surface paths.
|
||||||
|
|
||||||
|
"""
|
||||||
|
# Define aseg labels
|
||||||
|
|
||||||
|
# combined and individual aseg labels:
|
||||||
|
# - Left Striatum: left Caudate + Putamen + Accumbens
|
||||||
|
# - Right Striatum: right Caudate + Putamen + Accumbens
|
||||||
|
# - CorpusCallosum: 5 subregions combined
|
||||||
|
# - Cerebellum: brainstem + (left+right) cerebellum WM and GM
|
||||||
|
# - Ventricles: (left+right) lat.vent + inf.lat.vent + choroidplexus +
|
||||||
|
# 3rdVent + CSF
|
||||||
|
# - Lateral-Ventricle: lat.vent + inf.lat.vent + choroidplexus
|
||||||
|
# - 3rd-Ventricle: 3rd-Ventricle + CSF
|
||||||
|
|
||||||
|
aseg_labels = {
|
||||||
|
"CorpusCallosum": ["251", "252", "253", "254", "255"],
|
||||||
|
"Cerebellum": ["7", "8", "16", "46", "47"],
|
||||||
|
"Ventricles": ["4", "5", "14", "24", "31", "43", "44", "63"],
|
||||||
|
"3rd-Ventricle": ["14", "24"],
|
||||||
|
"4th-Ventricle": ["15"],
|
||||||
|
"Brain-Stem": ["16"],
|
||||||
|
"Left-Striatum": ["11", "12", "26"],
|
||||||
|
"Left-Lateral-Ventricle": ["4", "5", "31"],
|
||||||
|
"Left-Cerebellum-White-Matter": ["7"],
|
||||||
|
"Left-Cerebellum-Cortex": ["8"],
|
||||||
|
"Left-Thalamus-Proper": ["10"],
|
||||||
|
"Left-Caudate": ["11"],
|
||||||
|
"Left-Putamen": ["12"],
|
||||||
|
"Left-Pallidum": ["13"],
|
||||||
|
"Left-Hippocampus": ["17"],
|
||||||
|
"Left-Amygdala": ["18"],
|
||||||
|
"Left-Accumbens-area": ["26"],
|
||||||
|
"Left-VentralDC": ["28"],
|
||||||
|
"Right-Striatum": ["50", "51", "58"],
|
||||||
|
"Right-Lateral-Ventricle": ["43", "44", "63"],
|
||||||
|
"Right-Cerebellum-White-Matter": ["46"],
|
||||||
|
"Right-Cerebellum-Cortex": ["47"],
|
||||||
|
"Right-Thalamus-Proper": ["49"],
|
||||||
|
"Right-Caudate": ["50"],
|
||||||
|
"Right-Putamen": ["51"],
|
||||||
|
"Right-Pallidum": ["52"],
|
||||||
|
"Right-Hippocampus": ["53"],
|
||||||
|
"Right-Amygdala": ["54"],
|
||||||
|
"Right-Accumbens-area": ["58"],
|
||||||
|
"Right-VentralDC": ["60"],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: self._create_aseg_surface(
|
||||||
|
aseg_path=aseg_path,
|
||||||
|
norm_path=norm_path,
|
||||||
|
indices=indices,
|
||||||
|
)
|
||||||
|
for label, indices in aseg_labels.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
def _create_cortical_surfaces(
|
||||||
|
self,
|
||||||
|
lh_white_path: Path,
|
||||||
|
rh_white_path: Path,
|
||||||
|
lh_pial_path: Path,
|
||||||
|
rh_pial_path: Path,
|
||||||
|
) -> Dict[str, Path]:
|
||||||
|
"""Create cortical surfaces from FreeSurfer labels.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
lh_white_path : pathlib.Path
|
||||||
|
The FreeSurfer lh.white path.
|
||||||
|
rh_white_path : pathlib.Path
|
||||||
|
The FreeSurfer rh.white path.
|
||||||
|
lh_pial_path : pathlib.Path
|
||||||
|
The FreeSurfer lh.pial path.
|
||||||
|
rh_pial_path : pathlib.Path
|
||||||
|
The FreeSurfer rh.pial path.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
dict
|
||||||
|
Cortical surface label names with their paths as dictionary.
|
||||||
|
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"lh-white-2d": surf_to_vtk(
|
||||||
|
lh_white_path.resolve(),
|
||||||
|
(self._element_tempdir / "lh.white.vtk").resolve(),
|
||||||
|
),
|
||||||
|
"rh-white-2d": surf_to_vtk(
|
||||||
|
rh_white_path.resolve(),
|
||||||
|
(self._element_tempdir / "rh.white.vtk").resolve(),
|
||||||
|
),
|
||||||
|
"lh-pial-2d": surf_to_vtk(
|
||||||
|
lh_pial_path.resolve(),
|
||||||
|
(self._element_tempdir / "lh.pial.vtk").resolve(),
|
||||||
|
),
|
||||||
|
"rh-pial-2d": surf_to_vtk(
|
||||||
|
rh_pial_path.resolve(),
|
||||||
|
(self._element_tempdir / "rh.pial.vtk").resolve(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def compute(
|
||||||
|
self,
|
||||||
|
input: Dict[str, Any],
|
||||||
|
extra_input: Optional[Dict] = None,
|
||||||
|
) -> Dict:
|
||||||
|
"""Compute.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
input : dict
|
||||||
|
The FreeSurfer data as dictionary.
|
||||||
|
extra_input : dict, optional
|
||||||
|
The other fields in the pipeline data object (default None).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
dict
|
||||||
|
The computed result as dictionary. The dictionary has the following
|
||||||
|
keys:
|
||||||
|
|
||||||
|
* ``eigenvalues`` : dict of surface labels (str) and eigenvalues
|
||||||
|
(``np.ndarray``)
|
||||||
|
* ``eigenvectors`` : dict of surface labels (str) and eigenvectors
|
||||||
|
(``np.ndarray``) if ``keep_eigenvectors=True``
|
||||||
|
else None
|
||||||
|
* ``distances`` : dict of ``{left_label}_{right_label}`` (str) and
|
||||||
|
distance (float) if ``asymmetry=True`` else None
|
||||||
|
|
||||||
|
References
|
||||||
|
----------
|
||||||
|
.. [1] Wachinger, C., Golland, P., Kremen, W. et al. (2015)
|
||||||
|
BrainPrint: A discriminative characterization of brain
|
||||||
|
morphology.
|
||||||
|
NeuroImage, Volume 109, Pages 232-248.
|
||||||
|
https://doi.org/10.1016/j.neuroimage.2015.01.032.
|
||||||
|
.. [2] Reuter, M., Wolter, F.E., Peinecke, N. (2006)
|
||||||
|
Laplace-Beltrami spectra as 'Shape-DNA' of surfaces and solids.
|
||||||
|
Computer-Aided Design, Volume 38, Issue 4, Pages 342-366.
|
||||||
|
https://doi.org/10.1016/j.cad.2005.10.011.
|
||||||
|
|
||||||
|
"""
|
||||||
|
logger.debug("Computing BrainPrint")
|
||||||
|
|
||||||
|
# Create component-scoped tempdir
|
||||||
|
self._tempdir = WorkDirManager().get_tempdir(prefix="brainprint")
|
||||||
|
# Create element-scoped tempdir so that the files are
|
||||||
|
# available later as nibabel stores file path reference for
|
||||||
|
# loading on computation
|
||||||
|
self._element_tempdir = WorkDirManager().get_element_tempdir(
|
||||||
|
prefix="brainprint"
|
||||||
|
)
|
||||||
|
# Generate surfaces
|
||||||
|
surfaces = self._create_aseg_surfaces(
|
||||||
|
aseg_path=input["aseg"]["path"],
|
||||||
|
norm_path=input["norm"]["path"],
|
||||||
|
)
|
||||||
|
if not self.skip_cortex:
|
||||||
|
cortical_surfaces = self._create_cortical_surfaces(
|
||||||
|
lh_white_path=input["lh_white"]["path"],
|
||||||
|
rh_white_path=input["rh_white"]["path"],
|
||||||
|
lh_pial_path=input["lh_pial"]["path"],
|
||||||
|
rh_pial_path=input["rh_pial"]["path"],
|
||||||
|
)
|
||||||
|
surfaces.update(cortical_surfaces)
|
||||||
|
# Compute brainprint
|
||||||
|
eigenvalues, _ = compute_brainprint(
|
||||||
|
surfaces=surfaces,
|
||||||
|
keep_eigenvectors=self.keep_eigenvectors,
|
||||||
|
num=self.num,
|
||||||
|
norm=self.norm,
|
||||||
|
reweight=self.reweight,
|
||||||
|
use_cholmod=self.use_cholmod,
|
||||||
|
)
|
||||||
|
# Calculate distances (if required)
|
||||||
|
distances = None
|
||||||
|
if self.asymmetry:
|
||||||
|
distances = compute_asymmetry(
|
||||||
|
eigenvalues=eigenvalues,
|
||||||
|
distance=self.asymmetry_distance,
|
||||||
|
skip_cortex=self.skip_cortex,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete tempdir
|
||||||
|
WorkDirManager().delete_tempdir(self._tempdir)
|
||||||
|
|
||||||
|
output = {
|
||||||
|
"eigenvalues": {
|
||||||
|
"data": self._fix_nan(
|
||||||
|
[val[2:] for val in eigenvalues.values()]
|
||||||
|
).T,
|
||||||
|
"col_names": list(eigenvalues.keys()),
|
||||||
|
"row_names": [f"ev{i}" for i in range(self.num)],
|
||||||
|
"row_header_col_name": "eigenvalue",
|
||||||
|
},
|
||||||
|
"areas": {
|
||||||
|
"data": self._fix_nan(
|
||||||
|
[val[0] for val in eigenvalues.values()]
|
||||||
|
),
|
||||||
|
"col_names": list(eigenvalues.keys()),
|
||||||
|
},
|
||||||
|
"volumes": {
|
||||||
|
"data": self._fix_nan(
|
||||||
|
[val[1] for val in eigenvalues.values()]
|
||||||
|
),
|
||||||
|
"col_names": list(eigenvalues.keys()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if self.asymmetry:
|
||||||
|
output["distances"] = {
|
||||||
|
"data": self._fix_nan(list(distances.values())),
|
||||||
|
"col_names": list(distances.keys()),
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
|
||||||
|
def _fix_nan(
|
||||||
|
self,
|
||||||
|
input_data: List[Union[float, str, npt.ArrayLike]],
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Convert BrainPrint output with string NaN to ``numpy.nan``.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
input_data : list of str, float or numpy.ndarray-like
|
||||||
|
The data to convert.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
np.ndarray
|
||||||
|
The converted data as ``numpy.ndarray``.
|
||||||
|
|
||||||
|
"""
|
||||||
|
arr = np.asarray(input_data)
|
||||||
|
arr[arr == "NaN"] = np.nan
|
||||||
|
return arr.astype(np.float64)
|
||||||
|
|
||||||
|
# TODO: overridden to allow storing multiple outputs from single input;
|
||||||
|
# should be removed later
|
||||||
|
def store(
|
||||||
|
self,
|
||||||
|
type_: str,
|
||||||
|
feature: str,
|
||||||
|
out: Dict[str, Any],
|
||||||
|
storage: "BaseFeatureStorage",
|
||||||
|
) -> None:
|
||||||
|
"""Store.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
type_ : str
|
||||||
|
The data type to store.
|
||||||
|
feature : {"eigenvalues", "distances", "areas", "volumes"}
|
||||||
|
The feature name to store.
|
||||||
|
out : dict
|
||||||
|
The computed result as a dictionary to store.
|
||||||
|
storage : storage-like
|
||||||
|
The storage class, for example, SQLiteFeatureStorage.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
ValueError
|
||||||
|
If ``feature`` is invalid.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if feature == "eigenvalues":
|
||||||
|
output_type = "scalar_table"
|
||||||
|
elif feature in ["distances", "areas", "volumes"]:
|
||||||
|
output_type = "vector"
|
||||||
|
else:
|
||||||
|
raise_error(f"Unknown feature: {feature}")
|
||||||
|
|
||||||
|
logger.debug(f"Storing {output_type} in {storage}")
|
||||||
|
storage.store(kind=output_type, **out)
|
||||||
|
|
||||||
|
# TODO: overridden to allow storing multiple outputs from single input;
|
||||||
|
# should be removed later
|
||||||
|
def _fit_transform(
|
||||||
|
self,
|
||||||
|
input: Dict[str, Dict],
|
||||||
|
storage: Optional["BaseFeatureStorage"] = None,
|
||||||
|
) -> Dict:
|
||||||
|
"""Fit and transform.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
input : dict
|
||||||
|
The Junifer Data object.
|
||||||
|
storage : storage-like, optional
|
||||||
|
The storage class, for example, SQLiteFeatureStorage.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
dict
|
||||||
|
The processed output as a dictionary. If `storage` is provided,
|
||||||
|
empty dictionary is returned.
|
||||||
|
|
||||||
|
"""
|
||||||
|
out = {}
|
||||||
|
for type_ in self._on:
|
||||||
|
if type_ in input.keys():
|
||||||
|
logger.info(f"Computing {type_}")
|
||||||
|
t_input = input[type_]
|
||||||
|
extra_input = input.copy()
|
||||||
|
extra_input.pop(type_)
|
||||||
|
t_meta = t_input["meta"].copy()
|
||||||
|
t_meta["type"] = type_
|
||||||
|
|
||||||
|
# Returns multiple features
|
||||||
|
t_out = self.compute(input=t_input, extra_input=extra_input)
|
||||||
|
|
||||||
|
if storage is None:
|
||||||
|
out[type_] = {}
|
||||||
|
|
||||||
|
for feature_name, feature_data in t_out.items():
|
||||||
|
# Make deep copy of the feature data for manipulation
|
||||||
|
feature_data_copy = deepcopy(feature_data)
|
||||||
|
# Make deep copy of metadata and add to feature data
|
||||||
|
feature_data_copy["meta"] = deepcopy(t_meta)
|
||||||
|
# Update metadata for the feature,
|
||||||
|
# feature data is not manipulated, only meta
|
||||||
|
self.update_meta(feature_data_copy, "marker")
|
||||||
|
# Update marker feature's metadata name
|
||||||
|
feature_data_copy["meta"]["marker"][
|
||||||
|
"name"
|
||||||
|
] += f"_{feature_name}"
|
||||||
|
|
||||||
|
if storage is not None:
|
||||||
|
logger.info(f"Storing in {storage}")
|
||||||
|
self.store(
|
||||||
|
type_=type_,
|
||||||
|
feature=feature_name,
|
||||||
|
out=feature_data_copy,
|
||||||
|
storage=storage,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"No storage specified, returning dictionary"
|
||||||
|
)
|
||||||
|
out[type_][feature_name] = feature_data_copy
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
@ -72,7 +72,7 @@ class AFNIReHo:
|
||||||
Number of voxels in the neighbourhood, inclusive. Can be:
|
Number of voxels in the neighbourhood, inclusive. Can be:
|
||||||
|
|
||||||
* 7 : for facewise neighbours only
|
* 7 : for facewise neighbours only
|
||||||
* 19 : for face- and edge-wise nieghbours
|
* 19 : for face- and edge-wise neighbours
|
||||||
* 27 : for face-, edge-, and node-wise neighbors
|
* 27 : for face-, edge-, and node-wise neighbors
|
||||||
|
|
||||||
(default 27).
|
(default 27).
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ class JuniferReHo:
|
||||||
Number of voxels in the neighbourhood, inclusive. Can be:
|
Number of voxels in the neighbourhood, inclusive. Can be:
|
||||||
|
|
||||||
* 7 : for facewise neighbours only
|
* 7 : for facewise neighbours only
|
||||||
* 19 : for face- and edge-wise nieghbours
|
* 19 : for face- and edge-wise neighbours
|
||||||
* 27 : for face-, edge-, and node-wise neighbors
|
* 27 : for face-, edge-, and node-wise neighbors
|
||||||
* 125 : for 5x5 cuboidal volume
|
* 125 : for 5x5 cuboidal volume
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ class ReHoParcels(ReHoBase):
|
||||||
Number of voxels in the neighbourhood, inclusive. Can be:
|
Number of voxels in the neighbourhood, inclusive. Can be:
|
||||||
|
|
||||||
- 7 : for facewise neighbours only
|
- 7 : for facewise neighbours only
|
||||||
- 19 : for face- and edge-wise nieghbours
|
- 19 : for face- and edge-wise neighbours
|
||||||
- 27 : for face-, edge-, and node-wise neighbors
|
- 27 : for face-, edge-, and node-wise neighbors
|
||||||
|
|
||||||
* ``neigh_rad`` : positive float, optional
|
* ``neigh_rad`` : positive float, optional
|
||||||
|
|
@ -67,7 +67,7 @@ class ReHoParcels(ReHoBase):
|
||||||
Number of voxels in the neighbourhood, inclusive. Can be:
|
Number of voxels in the neighbourhood, inclusive. Can be:
|
||||||
|
|
||||||
* 7 : for facewise neighbours only
|
* 7 : for facewise neighbours only
|
||||||
* 19 : for face- and edge-wise nieghbours
|
* 19 : for face- and edge-wise neighbours
|
||||||
* 27 : for face-, edge-, and node-wise neighbors
|
* 27 : for face-, edge-, and node-wise neighbors
|
||||||
* 125 : for 5x5 cuboidal volume
|
* 125 : for 5x5 cuboidal volume
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ class ReHoSpheres(ReHoBase):
|
||||||
Number of voxels in the neighbourhood, inclusive. Can be:
|
Number of voxels in the neighbourhood, inclusive. Can be:
|
||||||
|
|
||||||
- 7 : for facewise neighbours only
|
- 7 : for facewise neighbours only
|
||||||
- 19 : for face- and edge-wise nieghbours
|
- 19 : for face- and edge-wise neighbours
|
||||||
- 27 : for face-, edge-, and node-wise neighbors
|
- 27 : for face-, edge-, and node-wise neighbors
|
||||||
|
|
||||||
* ``neigh_rad`` : positive float, optional
|
* ``neigh_rad`` : positive float, optional
|
||||||
|
|
@ -78,7 +78,7 @@ class ReHoSpheres(ReHoBase):
|
||||||
Number of voxels in the neighbourhood, inclusive. Can be:
|
Number of voxels in the neighbourhood, inclusive. Can be:
|
||||||
|
|
||||||
* 7 : for facewise neighbours only
|
* 7 : for facewise neighbours only
|
||||||
* 19 : for face- and edge-wise nieghbours
|
* 19 : for face- and edge-wise neighbours
|
||||||
* 27 : for face-, edge-, and node-wise neighbors
|
* 27 : for face-, edge-, and node-wise neighbors
|
||||||
* 125 : for 5x5 cuboidal volume
|
* 125 : for 5x5 cuboidal volume
|
||||||
|
|
||||||
|
|
|
||||||
47
junifer/markers/tests/test_brainprint.py
Normal file
47
junifer/markers/tests/test_brainprint.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
"""Provide tests for BrainPrint."""
|
||||||
|
|
||||||
|
# Authors: Synchon Mandal <s.mandal@fz-juelich.de>
|
||||||
|
# License: AGPL
|
||||||
|
|
||||||
|
import socket
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from junifer.datagrabber import DataladAOMICID1000
|
||||||
|
from junifer.datareader import DefaultDataReader
|
||||||
|
from junifer.markers import BrainPrint
|
||||||
|
from junifer.pipeline.utils import _check_freesurfer
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_output_type() -> None:
|
||||||
|
"""Test BrainPrint get_output_type()."""
|
||||||
|
marker = BrainPrint()
|
||||||
|
assert marker.get_output_type("FreeSurfer") == "vector"
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate() -> None:
|
||||||
|
"""Test BrainPrint validate()."""
|
||||||
|
marker = BrainPrint()
|
||||||
|
assert set(marker.validate(["FreeSurfer"])) == {"scalar_table", "vector"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
_check_freesurfer() is False, reason="requires FreeSurfer to be in PATH"
|
||||||
|
)
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
socket.gethostname() != "juseless",
|
||||||
|
The data takes quite some time to fetch and the computation is better half of an hour, so would exceed the limitations of the gh-actions standard runners. We can pull it off on gh-actions if we have a small testing dataset providing FreeSurfer derivatives. The data takes quite some time to fetch and the computation is better half of an hour, so would exceed the limitations of the gh-actions standard runners. We can pull it off on gh-actions if we have a small testing dataset providing FreeSurfer derivatives.
|
|||||||
|
reason="only for juseless",
|
||||||
|
)
|
||||||
|
def test_compute() -> None:
|
||||||
|
"""Test BrainPrint compute()."""
|
||||||
|
with DataladAOMICID1000(types="FreeSurfer") as dg:
|
||||||
|
# Fetch element
|
||||||
|
element = dg["sub-0001"]
|
||||||
|
# Fetch element data
|
||||||
|
element_data = DefaultDataReader().fit_transform(element)
|
||||||
|
# Initialize the marker
|
||||||
|
marker = BrainPrint()
|
||||||
|
# Compute the marker
|
||||||
|
feature_map = marker.fit_transform(element_data)
|
||||||
|
# Assert the output keys
|
||||||
|
assert {"eigenvalues", "areas", "volumes"} == set(feature_map.keys())
|
||||||
|
|
@ -49,6 +49,7 @@ dependencies = [
|
||||||
"httpx[http2]==0.26.0",
|
"httpx[http2]==0.26.0",
|
||||||
"tqdm==4.66.1",
|
"tqdm==4.66.1",
|
||||||
"templateflow>=23.0.0",
|
"templateflow>=23.0.0",
|
||||||
|
"lapy>=1.0.0,<2.0.0",
|
||||||
"importlib_metadata; python_version<'3.10'",
|
"importlib_metadata; python_version<'3.10'",
|
||||||
"looseversion==1.3.0; python_version>='3.12'",
|
"looseversion==1.3.0; python_version>='3.12'",
|
||||||
]
|
]
|
||||||
|
|
@ -91,7 +92,12 @@ docs = [
|
||||||
################
|
################
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
packages = ["junifer", "junifer.external.h5io.h5io"]
|
packages = [
|
||||||
|
"junifer",
|
||||||
|
"junifer.external.h5io.h5io",
|
||||||
|
"junifer.external.BrainPrint.brainprint",
|
||||||
|
"junifer.external.BrainPrint.brainprint.utils",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.setuptools_scm]
|
[tool.setuptools_scm]
|
||||||
version_scheme = "guess-next-dev"
|
version_scheme = "guess-next-dev"
|
||||||
|
|
@ -104,11 +110,12 @@ target-version = ["py38", "py39", "py310", "py311", "py312"]
|
||||||
extend-exclude = """
|
extend-exclude = """
|
||||||
(
|
(
|
||||||
junifer/external/h5io
|
junifer/external/h5io
|
||||||
|
| junifer/external/BrainPrint
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
[tool.codespell]
|
[tool.codespell]
|
||||||
skip = "*/auto_examples/*,*.html,.git/,*.pyc,*/_build/*,*/h5io/*"
|
skip = "*/auto_examples/*,*.html,.git/,*.pyc,*/_build/*,*/h5io/*,*/BrainPrint/*"
|
||||||
count = ""
|
count = ""
|
||||||
quiet-level = 3
|
quiet-level = 3
|
||||||
ignore-words = "ignore_words.txt"
|
ignore-words = "ignore_words.txt"
|
||||||
|
|
@ -152,6 +159,7 @@ select = [
|
||||||
extend-exclude = [
|
extend-exclude = [
|
||||||
"__init__.py",
|
"__init__.py",
|
||||||
"junifer/external/h5io",
|
"junifer/external/h5io",
|
||||||
|
"junifer/external/BrainPrint",
|
||||||
"docs",
|
"docs",
|
||||||
"examples",
|
"examples",
|
||||||
"tools",
|
"tools",
|
||||||
|
|
@ -197,6 +205,8 @@ known-third-party =[
|
||||||
"templateflow",
|
"templateflow",
|
||||||
"bct",
|
"bct",
|
||||||
"neurokit2",
|
"neurokit2",
|
||||||
|
"brainprint",
|
||||||
|
"lapy",
|
||||||
"pytest",
|
"pytest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -205,7 +215,7 @@ max-complexity = 20
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
minversion = "7.0"
|
minversion = "7.0"
|
||||||
addopts = "--ignore=junifer/external/h5io -vv"
|
addopts = "--ignore=junifer/external/h5io --ignore=junifer/external/BrainPrint -vv"
|
||||||
|
|
||||||
[tool.towncrier]
|
[tool.towncrier]
|
||||||
directory = "docs/changes/newsfragments"
|
directory = "docs/changes/newsfragments"
|
||||||
|
|
|
||||||
1
tox.ini
1
tox.ini
|
|
@ -78,6 +78,7 @@ omit =
|
||||||
*/tests/*
|
*/tests/*
|
||||||
*/junifer/configs/*
|
*/junifer/configs/*
|
||||||
*/junifer/external/h5io/*
|
*/junifer/external/h5io/*
|
||||||
|
*/junifer/external/BrainPrint/*
|
||||||
parallel = false
|
parallel = false
|
||||||
|
|
||||||
[coverage:report]
|
[coverage:report]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue
Why only for juseless?