From 38cf019ca14476ab479d7aa23f114c247c24a942 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Mon, 29 Apr 2024 17:06:00 +0200 Subject: [PATCH 01/20] update: add brainprint submodule --- .gitmodules | 3 +++ junifer/external/BrainPrint | 1 + 2 files changed, 4 insertions(+) create mode 160000 junifer/external/BrainPrint diff --git a/.gitmodules b/.gitmodules index 0c541549d..48097fc8c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "junifer/external/h5io"] path = junifer/external/h5io url = https://github.com/juaml/h5io +[submodule "junifer/external/BrainPrint"] + path = junifer/external/BrainPrint + url = https://github.com/juaml/BrainPrint.git diff --git a/junifer/external/BrainPrint b/junifer/external/BrainPrint new file mode 160000 index 000000000..13388aae0 --- /dev/null +++ b/junifer/external/BrainPrint @@ -0,0 +1 @@ +Subproject commit 13388aae03ce795e1a0c21e30df803e1e1473b36 -- 2.52.0 From 418b9d032ada3314eccfc95271672fd59b0a2612 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Tue, 7 May 2024 15:15:47 +0200 Subject: [PATCH 02/20] chore: update pyproject.toml to add brainprint --- pyproject.toml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a382d0037..3adbbb00c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,7 +91,11 @@ docs = [ ################ [tool.setuptools] -packages = ["junifer", "junifer.external.h5io.h5io"] +packages = [ + "junifer", + "junifer.external.h5io.h5io", + "junifer.external.BrainPrint.brainprint", +] [tool.setuptools_scm] version_scheme = "guess-next-dev" @@ -104,6 +108,7 @@ target-version = ["py38", "py39", "py310", "py311", "py312"] extend-exclude = """ ( junifer/external/h5io + | junifer/external/BrainPrint ) """ @@ -152,6 +157,7 @@ select = [ extend-exclude = [ "__init__.py", "junifer/external/h5io", + "junifer/external/BrainPrint", "docs", "examples", "tools", -- 2.52.0 From d620be9038ca10efe407ebbdd6dcad0e5e81ede5 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Tue, 14 May 2024 14:14:15 +0200 Subject: [PATCH 03/20] feat: add support for BrainPrint marker --- junifer/markers/__init__.py | 1 + junifer/markers/brainprint.py | 411 ++++++++++++++++++++++++++++++++++ 2 files changed, 412 insertions(+) create mode 100644 junifer/markers/brainprint.py diff --git a/junifer/markers/__init__.py b/junifer/markers/__init__.py index b8dbb0263..a829a28c6 100644 --- a/junifer/markers/__init__.py +++ b/junifer/markers/__init__.py @@ -23,3 +23,4 @@ from .temporal_snr import ( TemporalSNRParcels, TemporalSNRSpheres, ) +from .brainprint import BrainPrint diff --git a/junifer/markers/brainprint.py b/junifer/markers/brainprint.py new file mode 100644 index 000000000..019525eb9 --- /dev/null +++ b/junifer/markers/brainprint.py @@ -0,0 +1,411 @@ +"""Provide class for BrainPrint.""" + +# Authors: Synchon Mandal +# License: AGPL + +import uuid +from pathlib import Path +from typing import ( + Any, + ClassVar, + Dict, + List, + Optional, + Set, + Union, +) + +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 ..utils import logger, run_ext_cmd +from .base import BaseMarker + + +@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]] = {"brainprint"} + + 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"] + + 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" + + 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, eigenvectors = 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) + + return { + "eigenvalues": eigenvalues, + "eigenvectors": eigenvectors, + "distances": distances, + } -- 2.52.0 From 50b90ad96beace07ff7c939563db9c127c42c3c4 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Tue, 14 May 2024 15:12:50 +0200 Subject: [PATCH 04/20] chore: update BrainPrint submodule --- junifer/external/BrainPrint | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/junifer/external/BrainPrint b/junifer/external/BrainPrint index 13388aae0..adf2b5b5e 160000 --- a/junifer/external/BrainPrint +++ b/junifer/external/BrainPrint @@ -1 +1 @@ -Subproject commit 13388aae03ce795e1a0c21e30df803e1e1473b36 +Subproject commit adf2b5b5e3289126e3d66fcbd6cc8575ce5a277c -- 2.52.0 From d66ad89e7adf69222cb022859c5ff7a60d00124a Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Tue, 14 May 2024 15:13:04 +0200 Subject: [PATCH 05/20] update: update dependency for brainprint --- junifer/markers/brainprint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/junifer/markers/brainprint.py b/junifer/markers/brainprint.py index 019525eb9..a179a6a97 100644 --- a/junifer/markers/brainprint.py +++ b/junifer/markers/brainprint.py @@ -74,7 +74,7 @@ class BrainPrint(BaseMarker): }, ] - _DEPENDENCIES: ClassVar[Set[str]] = {"brainprint"} + _DEPENDENCIES: ClassVar[Set[str]] = {"lapy"} def __init__( self, -- 2.52.0 From 9c16ea0fd72f52310afaac0121913c49d69f4001 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Tue, 14 May 2024 15:13:59 +0200 Subject: [PATCH 06/20] update: add lapy as optional dep for junifer via brainprint --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 3adbbb00c..79ebfaf8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,12 +67,14 @@ junifer = "junifer.api.cli:cli" all = [ "bctpy==0.6.0", "neurokit2>=0.1.7", + "lapy>=1.0.0,<2.0.0", ] bct = ["bctpy==0.6.0"] onthefly = [ "bctpy==0.6.0" ] neurokit2 = ["neurokit2>=0.1.7"] +brainprint = ["lapy>=1.0.0,<2.0.0"] dev = ["tox", "pre-commit"] docs = [ "seaborn>=0.11.2,<0.13", -- 2.52.0 From 2052d82a197f4563701f8d1988b765c9294cca24 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Thu, 16 May 2024 10:36:59 +0200 Subject: [PATCH 07/20] update: override validate() for BrainPrint --- junifer/markers/brainprint.py | 94 ++++++++++++++++++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) diff --git a/junifer/markers/brainprint.py b/junifer/markers/brainprint.py index a179a6a97..1ab191247 100644 --- a/junifer/markers/brainprint.py +++ b/junifer/markers/brainprint.py @@ -3,7 +3,14 @@ # Authors: Synchon Mandal # License: AGPL +try: + from importlib.metadata import packages_distributions +except ImportError: # pragma: no cover + from importlib_metadata import packages_distributions + import uuid +from importlib.util import find_spec +from itertools import chain from pathlib import Path from typing import ( Any, @@ -22,7 +29,8 @@ from ..external.BrainPrint.brainprint.brainprint import ( ) from ..external.BrainPrint.brainprint.surfaces import surf_to_vtk from ..pipeline import WorkDirManager -from ..utils import logger, run_ext_cmd +from ..pipeline.utils import check_ext_dependencies +from ..utils import logger, raise_error, run_ext_cmd from .base import BaseMarker @@ -109,6 +117,7 @@ class BrainPrint(BaseMarker): """ 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. @@ -125,6 +134,89 @@ class BrainPrint(BaseMarker): """ 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, -- 2.52.0 From 3c71a1e1399f054ae88a14986754429aa93fdad4 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Thu, 16 May 2024 15:30:01 +0200 Subject: [PATCH 08/20] update: override _fit_transform() for BrainPrint --- junifer/markers/brainprint.py | 71 +++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/junifer/markers/brainprint.py b/junifer/markers/brainprint.py index 1ab191247..89e7f1c9d 100644 --- a/junifer/markers/brainprint.py +++ b/junifer/markers/brainprint.py @@ -9,10 +9,12 @@ 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, @@ -34,6 +36,10 @@ 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. @@ -501,3 +507,68 @@ class BrainPrint(BaseMarker): "eigenvectors": eigenvectors, "distances": distances, } + + + # 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.copy().items(): + t_meta_copy = deepcopy(t_meta) + t_meta_copy["marker"]["name"] += f"_{feature_name}" + # Add metadata to feature data + feature_data["meta"] = t_meta_copy + # Update metadata for the feature, + # feature data is not manipulated + self.update_meta(t_out, "marker") + + if storage is not None: + logger.info(f"Storing in {storage}") + self.store( + type_=type_, + feature=feature_name, + out=feature_data, + storage=storage, + ) + else: + logger.info( + "No storage specified, returning dictionary" + ) + out[type_][feature_name] = feature_data + + return out -- 2.52.0 From e73c0f3d7aab2cb327d2bfb3ce706361601f9721 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Thu, 16 May 2024 15:30:40 +0200 Subject: [PATCH 09/20] update: override store() for BrainPrint --- junifer/markers/brainprint.py | 37 +++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/junifer/markers/brainprint.py b/junifer/markers/brainprint.py index 89e7f1c9d..abe47f8f7 100644 --- a/junifer/markers/brainprint.py +++ b/junifer/markers/brainprint.py @@ -508,6 +508,43 @@ class BrainPrint(BaseMarker): "distances": distances, } + # 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 -- 2.52.0 From 39403897e69f15dc346cc019069a4554acd0bcd6 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Thu, 16 May 2024 16:09:34 +0200 Subject: [PATCH 10/20] update: correct output for BrainPrint.compute() --- junifer/markers/brainprint.py | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/junifer/markers/brainprint.py b/junifer/markers/brainprint.py index abe47f8f7..a9f1a7854 100644 --- a/junifer/markers/brainprint.py +++ b/junifer/markers/brainprint.py @@ -502,14 +502,31 @@ class BrainPrint(BaseMarker): # Delete tempdir WorkDirManager().delete_tempdir(self._tempdir) - return { - "eigenvalues": eigenvalues, - "eigenvectors": eigenvectors, - "distances": distances, + output = { + "eigenvalues": { + "data": [val[2:, :] for val in eigenvalues.values()], + "col_names": list(eigenvalues.keys()), + "row_names": [f"ev{i}" for i in range(self.num)], + "row_header_col_name": "eigenvalue", + }, + "areas": { + "data": [val[0, :] for val in eigenvalues.values()], + "col_names": list(eigenvalues.keys()), + }, + "volumes": { + "data": [val[1, :] for val in eigenvalues.values()], + "col_names": list(eigenvalues.keys()), + }, } + if self.asymmetry: + output["distances"] = { + "data": list(distances.values()), + "col_names": list(distances.keys()), + } + return output - # TODO: overridden to allow storing multiple outputs from single input; should - # be removed later + # TODO: overridden to allow storing multiple outputs from single input; + # should be removed later def store( self, type_: str, @@ -546,8 +563,8 @@ class BrainPrint(BaseMarker): 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 + # TODO: overridden to allow storing multiple outputs from single input; + # should be removed later def _fit_transform( self, input: Dict[str, Dict], -- 2.52.0 From 79ecbbec15cfed1fb23c220dae09589faf3598d0 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Fri, 17 May 2024 12:36:52 +0200 Subject: [PATCH 11/20] update: make lapy mandatory dependency for junifer --- junifer/api/tests/test_api_utils.py | 2 ++ pyproject.toml | 10 ++++++---- tox.ini | 1 + 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/junifer/api/tests/test_api_utils.py b/junifer/api/tests/test_api_utils.py index 84eb2c286..28ae2ac3a 100644 --- a/junifer/api/tests/test_api_utils.py +++ b/junifer/api/tests/test_api_utils.py @@ -45,6 +45,7 @@ def test_get_dependency_information_short() -> None: "httpx", "tqdm", "templateflow", + "lapy", "looseversion", ] @@ -74,6 +75,7 @@ def test_get_dependency_information_long() -> None: "httpx", "tqdm", "templateflow", + "lapy", ] for key in dependency_list: assert key in dependency_information_keys diff --git a/pyproject.toml b/pyproject.toml index 79ebfaf8e..f8ddf7c7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ dependencies = [ "httpx[http2]==0.26.0", "tqdm==4.66.1", "templateflow>=23.0.0", + "lapy>=1.0.0,<2.0.0", "importlib_metadata; python_version<'3.10'", "looseversion==1.3.0; python_version>='3.12'", ] @@ -67,14 +68,12 @@ junifer = "junifer.api.cli:cli" all = [ "bctpy==0.6.0", "neurokit2>=0.1.7", - "lapy>=1.0.0,<2.0.0", ] bct = ["bctpy==0.6.0"] onthefly = [ "bctpy==0.6.0" ] neurokit2 = ["neurokit2>=0.1.7"] -brainprint = ["lapy>=1.0.0,<2.0.0"] dev = ["tox", "pre-commit"] docs = [ "seaborn>=0.11.2,<0.13", @@ -97,6 +96,7 @@ packages = [ "junifer", "junifer.external.h5io.h5io", "junifer.external.BrainPrint.brainprint", + "junifer.external.BrainPrint.brainprint.utils", ] [tool.setuptools_scm] @@ -115,7 +115,7 @@ extend-exclude = """ """ [tool.codespell] -skip = "*/auto_examples/*,*.html,.git/,*.pyc,*/_build/*,*/h5io/*" +skip = "*/auto_examples/*,*.html,.git/,*.pyc,*/_build/*,*/h5io/*,*/BrainPrint/*" count = "" quiet-level = 3 ignore-words = "ignore_words.txt" @@ -205,6 +205,8 @@ known-third-party =[ "templateflow", "bct", "neurokit2", + "brainprint", + "lapy", "pytest", ] @@ -213,7 +215,7 @@ max-complexity = 20 [tool.pytest.ini_options] minversion = "7.0" -addopts = "--ignore=junifer/external/h5io -vv" +addopts = "--ignore=junifer/external/h5io --ignore=junifer/external/BrainPrint -vv" [tool.towncrier] directory = "docs/changes/newsfragments" diff --git a/tox.ini b/tox.ini index 5bcaa261b..acbbd373a 100644 --- a/tox.ini +++ b/tox.ini @@ -78,6 +78,7 @@ omit = */tests/* */junifer/configs/* */junifer/external/h5io/* + */junifer/external/BrainPrint/* parallel = false [coverage:report] -- 2.52.0 From 15604cd4e1577352a9c8da709b9bb262b23c143f Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Tue, 21 May 2024 14:52:13 +0200 Subject: [PATCH 12/20] fix: correct usage of uuid4 in BrainPrint --- junifer/markers/brainprint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/junifer/markers/brainprint.py b/junifer/markers/brainprint.py index a9f1a7854..6a4617ff7 100644 --- a/junifer/markers/brainprint.py +++ b/junifer/markers/brainprint.py @@ -246,7 +246,7 @@ class BrainPrint(BaseMarker): Path to the generated surface in VTK format. """ - tempfile_prefix = f"aseg.{uuid.uuid4}" + tempfile_prefix = f"aseg.{uuid.uuid4()}" # Set mri_binarize command mri_binarize_output_path = self._tempdir / f"{tempfile_prefix}.mgz" -- 2.52.0 From fdad118323cfb273cf7f2af30ebc429655b2d3fa Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Tue, 21 May 2024 14:52:40 +0200 Subject: [PATCH 13/20] fix: correct indexing of BrainPrint.compute() output --- junifer/markers/brainprint.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/junifer/markers/brainprint.py b/junifer/markers/brainprint.py index 6a4617ff7..faa2f0423 100644 --- a/junifer/markers/brainprint.py +++ b/junifer/markers/brainprint.py @@ -504,17 +504,17 @@ class BrainPrint(BaseMarker): output = { "eigenvalues": { - "data": [val[2:, :] for val in eigenvalues.values()], + "data": [val[2:] for val in eigenvalues.values()], "col_names": list(eigenvalues.keys()), "row_names": [f"ev{i}" for i in range(self.num)], "row_header_col_name": "eigenvalue", }, "areas": { - "data": [val[0, :] for val in eigenvalues.values()], + "data": [val[0] for val in eigenvalues.values()], "col_names": list(eigenvalues.keys()), }, "volumes": { - "data": [val[1, :] for val in eigenvalues.values()], + "data": [val[1] for val in eigenvalues.values()], "col_names": list(eigenvalues.keys()), }, } -- 2.52.0 From ca66212088dc917ddec4d87b74166d304aa46db4 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Fri, 24 May 2024 14:59:54 +0200 Subject: [PATCH 14/20] fix: process string NaN before storing data in BrainPrint.compute() --- junifer/markers/brainprint.py | 40 ++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/junifer/markers/brainprint.py b/junifer/markers/brainprint.py index faa2f0423..d1a1cda37 100644 --- a/junifer/markers/brainprint.py +++ b/junifer/markers/brainprint.py @@ -24,6 +24,9 @@ from typing import ( Union, ) +import numpy as np +import numpy.typing as npt + from ..api.decorators import register_marker from ..external.BrainPrint.brainprint.brainprint import ( compute_asymmetry, @@ -88,7 +91,7 @@ class BrainPrint(BaseMarker): }, ] - _DEPENDENCIES: ClassVar[Set[str]] = {"lapy"} + _DEPENDENCIES: ClassVar[Set[str]] = {"lapy", "numpy"} def __init__( self, @@ -504,27 +507,54 @@ class BrainPrint(BaseMarker): output = { "eigenvalues": { - "data": [val[2:] for val in eigenvalues.values()], + "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": [val[0] for val in eigenvalues.values()], + "data": self._fix_nan( + [val[0] for val in eigenvalues.values()] + ), "col_names": list(eigenvalues.keys()), }, "volumes": { - "data": [val[1] for val in eigenvalues.values()], + "data": self._fix_nan( + [val[1] for val in eigenvalues.values()] + ), "col_names": list(eigenvalues.keys()), }, } if self.asymmetry: output["distances"] = { - "data": list(distances.values()), + "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( -- 2.52.0 From a63a0c0b8be2c0c9d072c7ce75f4ed8a0cbe9b0d Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Fri, 24 May 2024 15:01:46 +0200 Subject: [PATCH 15/20] fix: use deepcopy to store multiple features in BrainPrint._fit_transform() --- junifer/markers/brainprint.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/junifer/markers/brainprint.py b/junifer/markers/brainprint.py index d1a1cda37..5db124f6a 100644 --- a/junifer/markers/brainprint.py +++ b/junifer/markers/brainprint.py @@ -632,27 +632,31 @@ class BrainPrint(BaseMarker): if storage is None: out[type_] = {} - for feature_name, feature_data in t_out.copy().items(): - t_meta_copy = deepcopy(t_meta) - t_meta_copy["marker"]["name"] += f"_{feature_name}" - # Add metadata to feature data - feature_data["meta"] = t_meta_copy + 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 - self.update_meta(t_out, "marker") + # 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, + out=feature_data_copy, storage=storage, ) else: logger.info( "No storage specified, returning dictionary" ) - out[type_][feature_name] = feature_data + out[type_][feature_name] = feature_data_copy return out -- 2.52.0 From 55779f49e3201ef9742ca509d7dec2a9e52d3e1a Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Fri, 24 May 2024 15:02:24 +0200 Subject: [PATCH 16/20] chore: ignore eigenvectors for now in BrainPrint.compute() --- junifer/markers/brainprint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/junifer/markers/brainprint.py b/junifer/markers/brainprint.py index 5db124f6a..d9e16b0cd 100644 --- a/junifer/markers/brainprint.py +++ b/junifer/markers/brainprint.py @@ -485,7 +485,7 @@ class BrainPrint(BaseMarker): ) surfaces.update(cortical_surfaces) # Compute brainprint - eigenvalues, eigenvectors = compute_brainprint( + eigenvalues, _ = compute_brainprint( surfaces=surfaces, keep_eigenvectors=self.keep_eigenvectors, num=self.num, -- 2.52.0 From 45346507cd68d522763ad339d0be7a9e89f5f70a Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Fri, 24 May 2024 15:04:58 +0200 Subject: [PATCH 17/20] fix: use deepcopy for copying type pattern in PatternDataGrabber.get_item() --- junifer/datagrabber/pattern.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/junifer/datagrabber/pattern.py b/junifer/datagrabber/pattern.py index 925b2981c..ef46ecdb3 100644 --- a/junifer/datagrabber/pattern.py +++ b/junifer/datagrabber/pattern.py @@ -6,6 +6,7 @@ # License: AGPL import re +from copy import deepcopy from pathlib import Path from typing import Dict, List, Optional, Tuple, Union @@ -364,7 +365,7 @@ class PatternDataGrabber(BaseDataGrabber): # Data type dictionary t_pattern = self.patterns[t_type] # 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 for k, v in t_pattern.items(): # Resolve pattern for base data type -- 2.52.0 From 74007f033a036a24d658f39bf07edb5da9b830ef Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Fri, 24 May 2024 15:17:59 +0200 Subject: [PATCH 18/20] update: add tests for BrainPrint --- junifer/markers/tests/test_brainprint.py | 47 ++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 junifer/markers/tests/test_brainprint.py diff --git a/junifer/markers/tests/test_brainprint.py b/junifer/markers/tests/test_brainprint.py new file mode 100644 index 000000000..1e6110ee3 --- /dev/null +++ b/junifer/markers/tests/test_brainprint.py @@ -0,0 +1,47 @@ +"""Provide tests for BrainPrint.""" + +# Authors: Synchon Mandal +# 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", + 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()) -- 2.52.0 From 5eba4e45ad40989312d134f52b18276760567341 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Fri, 24 May 2024 15:21:37 +0200 Subject: [PATCH 19/20] chore: fix codespell --- ignore_words.txt | 1 + junifer/data/tests/test_masks.py | 2 +- junifer/markers/reho/_afni_reho.py | 2 +- junifer/markers/reho/_junifer_reho.py | 2 +- junifer/markers/reho/reho_parcels.py | 4 ++-- junifer/markers/reho/reho_spheres.py | 4 ++-- 6 files changed, 8 insertions(+), 7 deletions(-) diff --git a/ignore_words.txt b/ignore_words.txt index 0760787ff..d0940ab0b 100644 --- a/ignore_words.txt +++ b/ignore_words.txt @@ -4,3 +4,4 @@ chang sepulcre arange sinc +whit diff --git a/junifer/data/tests/test_masks.py b/junifer/data/tests/test_masks.py index 2b079bdfb..5851e7ae7 100644 --- a/junifer/data/tests/test_masks.py +++ b/junifer/data/tests/test_masks.py @@ -350,7 +350,7 @@ def test_get_mask_errors() -> None: with pytest.raises(ValueError, match=r"callable params"): 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( ValueError, match=r" At least one mask is required." ): diff --git a/junifer/markers/reho/_afni_reho.py b/junifer/markers/reho/_afni_reho.py index 3cbaf8d12..c027726e4 100644 --- a/junifer/markers/reho/_afni_reho.py +++ b/junifer/markers/reho/_afni_reho.py @@ -72,7 +72,7 @@ class AFNIReHo: Number of voxels in the neighbourhood, inclusive. Can be: * 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 (default 27). diff --git a/junifer/markers/reho/_junifer_reho.py b/junifer/markers/reho/_junifer_reho.py index 2f1d0d12f..47191fa64 100644 --- a/junifer/markers/reho/_junifer_reho.py +++ b/junifer/markers/reho/_junifer_reho.py @@ -60,7 +60,7 @@ class JuniferReHo: Number of voxels in the neighbourhood, inclusive. Can be: * 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 * 125 : for 5x5 cuboidal volume diff --git a/junifer/markers/reho/reho_parcels.py b/junifer/markers/reho/reho_parcels.py index c00e8841d..e8e6327a2 100644 --- a/junifer/markers/reho/reho_parcels.py +++ b/junifer/markers/reho/reho_parcels.py @@ -37,7 +37,7 @@ class ReHoParcels(ReHoBase): Number of voxels in the neighbourhood, inclusive. Can be: - 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 * ``neigh_rad`` : positive float, optional @@ -67,7 +67,7 @@ class ReHoParcels(ReHoBase): Number of voxels in the neighbourhood, inclusive. Can be: * 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 * 125 : for 5x5 cuboidal volume diff --git a/junifer/markers/reho/reho_spheres.py b/junifer/markers/reho/reho_spheres.py index 453004b71..869c4658a 100644 --- a/junifer/markers/reho/reho_spheres.py +++ b/junifer/markers/reho/reho_spheres.py @@ -48,7 +48,7 @@ class ReHoSpheres(ReHoBase): Number of voxels in the neighbourhood, inclusive. Can be: - 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 * ``neigh_rad`` : positive float, optional @@ -78,7 +78,7 @@ class ReHoSpheres(ReHoBase): Number of voxels in the neighbourhood, inclusive. Can be: * 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 * 125 : for 5x5 cuboidal volume -- 2.52.0 From 5a53992104991c3c32a2af87db5013dc304b0322 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Fri, 24 May 2024 15:24:32 +0200 Subject: [PATCH 20/20] chore: add changelog 344.feature --- docs/changes/newsfragments/344.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/changes/newsfragments/344.feature diff --git a/docs/changes/newsfragments/344.feature b/docs/changes/newsfragments/344.feature new file mode 100644 index 000000000..33db2863b --- /dev/null +++ b/docs/changes/newsfragments/344.feature @@ -0,0 +1 @@ +Add support for `BrainPrint `_ marker by `Synchon Mandal`_ -- 2.52.0