diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index b6fe1ead..af8ba980 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -32,11 +32,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - name: Codespell diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index dfeef89c..d21b189c 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -55,11 +55,11 @@ jobs: echo "DEPLOY_TYPE=pr" >> $GITHUB_OUTPUT fi - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: ${{ env.MIN_PYTHON_VERSION }} cache: "pip" diff --git a/.github/workflows/pycodestyle.yml b/.github/workflows/pycodestyle.yml index eb7f9d06..5c6a7675 100644 --- a/.github/workflows/pycodestyle.yml +++ b/.github/workflows/pycodestyle.yml @@ -32,11 +32,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/.github/workflows/pydoclint.yml b/.github/workflows/pydoclint.yml index fba3a7e2..e0302db6 100644 --- a/.github/workflows/pydoclint.yml +++ b/.github/workflows/pydoclint.yml @@ -34,11 +34,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/.github/workflows/release-documentation.yml b/.github/workflows/release-documentation.yml index d4402134..7337fd5b 100644 --- a/.github/workflows/release-documentation.yml +++ b/.github/workflows/release-documentation.yml @@ -33,11 +33,11 @@ jobs: shell: bash run: echo "BRANCH=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_OUTPUT - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: ${{ env.MIN_PYTHON_VERSION }} cache: "pip" diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 3a983af2..421f3f09 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -32,11 +32,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index c65b24d2..22fe014a 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -5,7 +5,6 @@ # .. code-block:: bash # # $ nosetests --with-coverage --cover-package=brainprep --verbosity=2 -# --with-doctest --doctest-options='+ELLIPSIS,+NORMALIZE_WHITESPACE' ### name: "testing[nosetests]" @@ -34,11 +33,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} cache: "pip" @@ -48,7 +47,7 @@ jobs: python -m pip install --progress-bar off ".[ci]" - name: Run unit tests run: | - nosetests --with-coverage --cover-package=brainprep --verbosity=2 --with-doctest --doctest-options='+ELLIPSIS,+NORMALIZE_WHITESPACE' + nosetests --with-coverage --cover-package=brainprep --verbosity=2 - name: Coveralls if: matrix.python-version == 3.12 env: diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8c83e830..44eea125 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,7 @@ .. -*- mode: rst -*- -2.1.0.dev + +2.2.0.dev ========= HIGHLIGHTS @@ -9,19 +10,52 @@ HIGHLIGHTS NEW --- +Fixes +----- + +Enhancements +------------ + +Changes +------- + + +2.1.0 +===== + +HIGHLIGHTS +---------- + +This release optimizes existing or introduces new workflows. Please review +the modifications below. + +NEW +--- + - :bdg-success:`Enhancement` Add the dmriprep workflow. -- :bdg-success:`Enhancement` Add the tbss workflow. -- :bdg-success:`Enhancement` Add the mrophologist workflow. +- :bdg-success:`Enhancement` Add the morphologist workflow. Fixes ----- +- :bdg-danger:`Deprecation` Fix the containers that are using `mri_synthstrip`. +- :bdg-danger:`Deprecation` The run mapping file has been moved to avoid + conflicts with FreeSurfer. + Enhancements ------------ +- :bdg-success:`Enhancement` Add signature hook. +- :bdg-success:`Enhancement` Add live comand line monitoring support. +- :bdg-success:`Enhancement` Support multi-modality in Quasi-Raw workflow. +- :bdg-success:`Enhancement` Add `quick` mode in Quasi-Raw workflow. +- :bdg-success:`Enhancement` Support multi-modality in Deface workflow. + Changes ------- +- :bdg-danger:`Deprecation` Optimize the Quasi-Raw workflow steps. + 2.0.0 ===== @@ -29,16 +63,16 @@ Changes HIGHLIGHTS ---------- -his is a major release featuring significant changes to both the API and +This is a major release featuring significant changes to both the API and CLI. Please review the modifications below. NEW --- - :bdg-success:`Doc` Create doc with `furo `_. -- :bdg-success:`Enhancement` Worflows generate a report file. +- :bdg-success:`Enhancement` Workflows generate a report file. - :bdg-success:`Enhancement` Anonymize workflow outputs. -- :bdg-success:`Enhancement` Worflows generate BIDS-compliant organization. +- :bdg-success:`Enhancement` workflows generate BIDS-compliant organization. - :bdg-success:`Enhancement` New workflows can generate HTML reporting. - :bdg-success:`Datasets` Toy datasets have been added to test the module. - :bdg-success:`Enhancement` Quasi-RAW preprocessing compute the brain mask @@ -82,19 +116,18 @@ Changes 0.0.2 ===== -**Released September 2022** - HIGHLIGHTS ---------- -- :bdg-success:`API` This release includes different workflows to process - antomical, functional and diffusion MR images. -- :bdg-success:`API` All workflows are integrated in a dedicated container - to enforce reproducible research. +This is the legacy release of brainprep. NEW --- +- :bdg-success:`API` This release includes different workflows to process + antomical, functional and diffusion MR images. +- :bdg-success:`API` All workflows are integrated in a dedicated container + to enforce reproducible research. - :bdg-success:`API` The following workflows are released: * fsreconall @@ -114,13 +147,3 @@ NEW * tbss-preproc * tbss * dmriprep - -Fixes ------ - -Enhancements ------------- - -Changes -------- - diff --git a/README.rst b/README.rst index 14b2acda..b233bb83 100644 --- a/README.rst +++ b/README.rst @@ -14,27 +14,27 @@ **Development** -.. image:: https://coveralls.io/repos/brainprepdesk/brainprep/badge.svg?branch=dev&service=github +.. image:: https://coveralls.io/repos/brainprepdesk/brainprep/badge.svg?service=github :target: https://coveralls.io/github/brainprepdesk/brainprep :alt: Coveralls -.. image:: https://github.com/brainprepdesk/brainprep/actions/workflows/testing.yml/badge.svg?branch=dev +.. image:: https://github.com/brainprepdesk/brainprep/actions/workflows/testing.yml/badge.svg :target: https://github.com/brainprepdesk/brainprep/actions :alt: Testing Status -.. image:: https://github.com/brainprepdesk/brainprep/actions/workflows/pycodestyle.yml/badge.svg?branch=dev +.. image:: https://github.com/brainprepdesk/brainprep/actions/workflows/pycodestyle.yml/badge.svg :target: https://github.com/brainprepdesk/brainprep/actions :alt: PyCodeStyle -.. image:: https://github.com/brainprepdesk/brainprep/actions/workflows/ruff.yml/badge.svg?branch=dev +.. image:: https://github.com/brainprepdesk/brainprep/actions/workflows/ruff.yml/badge.svg :target: https://github.com/brainprepdesk/brainprep/actions :alt: Ruff Linter -.. image:: https://github.com/brainprepdesk/brainprep/actions/workflows/pydoclint.yml/badge.svg?branch=dev +.. image:: https://github.com/brainprepdesk/brainprep/actions/workflows/pydoclint.yml/badge.svg :target: https://github.com/brainprepdesk/brainprep/actions :alt: PyDocLint -.. image:: https://github.com/brainprepdesk/brainprep/actions/workflows/documentation.yml/badge.svg?branch=dev +.. image:: https://github.com/brainprepdesk/brainprep/actions/workflows/documentation.yml/badge.svg :target: https://brainprepdesk.github.io/brainprep :alt: Documentation Status diff --git a/brainprep/_version.py b/brainprep/_version.py index 8c0d5d5b..9aa3f903 100644 --- a/brainprep/_version.py +++ b/brainprep/_version.py @@ -1 +1 @@ -__version__ = "2.0.0" +__version__ = "2.1.0" diff --git a/brainprep/cli.py b/brainprep/cli.py index 6aa51088..ca6cea9e 100755 --- a/brainprep/cli.py +++ b/brainprep/cli.py @@ -22,13 +22,17 @@ import fire +import brainprep.interfaces as interfaces import brainprep.workflow as wf from brainprep.config import DEFAULT_OPTIONS +from brainprep.utils import coerce_to_list def make_wrapped( fn: Callable, - is_vbm: bool = False) -> Callable: + is_vbm: bool = False, + is_dmriprep: bool = False, + is_interface: bool = False) -> Callable: """ Wrap a workflow function and extend its signature with global configuration parameters. @@ -54,8 +58,18 @@ def make_wrapped( If ``False`` (default), VBM-specific configuration parameters such as ``cat12_file``, ``spm12_dir``, ``matlab_dir``, ``tpm_file``, and ``darteltpm_file`` are excluded from the - generated signature. If ``True``, all configuration parameters + generated signature. If ``True``, all these configuration parameters are included. Default False. + is_dmriprep : bool + Whether the wrapped function corresponds to a dMRIprep workflow. + If ``False`` (default), dMRIprep-specific configuration parameters + such as ``mni_2iso_file``, and ``geolab_atlas_dir`` are excluded from + the generated signature. If ``True``, all these configuration parameters + are included. Default False. + is_interface : bool + Whether the wrapped function corresponds to a brainprep interface. + If ``True``, the ``dryrun`` configuration parameters is excluded from + the generated signature. Default False. Returns ------- @@ -83,17 +97,54 @@ def wrapped_fn(*args, **kwargs): for key in DEFAULT_OPTIONS if key in kwargs } + + sig = inspect.signature(fn) + args = list(args) + for idx, param in enumerate(sig.parameters.values()): + if param.name == "entities": + if param.kind in ( + param.POSITIONAL_ONLY, + param.POSITIONAL_OR_KEYWORD): + val = coerce_to_list(args[idx], list[str]) + if len(val) == 1: + val = val[0] + args[idx] = ( + [ + dict( + item_text.split("-") + for item_text in dict_text.split("_") + ) + for dict_text in val + ] + if isinstance(val, list) + else dict( + item_text.split("-") + for item_text in val.split("_") + ) + ) + break + args = tuple(args) + with Config(**config_params): return fn(*args, **kwargs) sig = inspect.signature(fn) kwargs_in_keys = "kwargs" in sig.parameters params = list(sig.parameters.values()) + if is_interface: + params = [ + param + for param in params + if param.name != "dryrun" + ] for key, val in DEFAULT_OPTIONS.items(): - if not is_vbm and key in ( + if not is_interface and not is_vbm and key in ( "cat12_file", "spm12_dir", "matlab_dir", "tpm_file", "darteltpm_file"): continue + if not is_interface and not is_dmriprep and key in ( + "mni_2iso_file", "geolab_atlas_dir"): + continue param = inspect.Parameter( key, inspect.Parameter.KEYWORD_ONLY, @@ -149,7 +200,21 @@ def main(): "group-level-vbm": wf.brainprep_group_vbm, "subject-level-fmriprep": wf.brainprep_fmriprep, "group-level-fmriprep": wf.brainprep_group_fmriprep, + "subject-level-sulcirec": wf.brainprep_sulcirec, + "group-level-sulcirec": wf.brainprep_group_sulcirec, + "subject-level-dmriprep": wf.brainprep_dmriprep, + "group-level-reporting": wf.brainprep_group_reporting, } for key, fn in commands.items(): - commands[key] = make_wrapped(fn, is_vbm=key.endswith("vbm")) + commands[key] = make_wrapped( + fn, + is_vbm=key.endswith("vbm"), + is_dmriprep=key.endswith("dmriprep"), + ) + for key, fn in inspect.getmembers(interfaces, inspect.isfunction): + key = key.replace("_", "-") + commands.setdefault("interface", {})[key] = make_wrapped( + fn, + is_interface=True, + ) fire.Fire(commands) diff --git a/brainprep/config.py b/brainprep/config.py index 6538d501..e811fe55 100644 --- a/brainprep/config.py +++ b/brainprep/config.py @@ -34,6 +34,12 @@ "/opt/cat12/spm12_mcr/home/gaser/gaser/spm/spm12/toolbox/" "cat12/templates_MNI152NLin2009cAsym/Template_1_Dartel.nii" ), + "mni_2iso_file": Path( + "/opt/fsl/data/standard/MNI152_T1_2mm.nii.gz" + ), + "geolab_atlas_dir": Path( + "/opt/geolab/atlas" + ), } brainprep_options = contextvars.ContextVar( diff --git a/brainprep/decorators.py b/brainprep/decorators.py index b9633f2c..b6fcc662 100644 --- a/brainprep/decorators.py +++ b/brainprep/decorators.py @@ -14,7 +14,8 @@ import inspect import json import platform -import subprocess +import pprint +import time from collections.abc import Callable, Iterable from pathlib import Path from typing import ( @@ -33,7 +34,6 @@ trace_module_calls, ) from .typing import ( - Directory, File, ) from .utils import ( @@ -41,6 +41,7 @@ coerce_to_list, coerce_to_path, parse_bids_keys, + print_call, print_command, print_title, ) @@ -65,16 +66,6 @@ class Hook: ``before_call`` returns the inputs unchanged, and ``after_call`` returns the outputs unchanged. - Methods - ------- - before_call(func, inputs) - Hook executed before the wrapped function is called. - Must return a dictionary of (possibly modified) inputs. - - after_call(func, outputs) - Hook executed after the wrapped function returns. - Must return the (possibly modified) output value. - Notes ----- Subclasses may override one or both methods. If a method is not @@ -87,14 +78,22 @@ def before_call( func: Callable, inputs: dict[str, Any], ) -> dict[str, Any]: - """Transform and inspect inputs before the function call.""" + """ + Hook executed before the wrapped function is called. + Transform and/or inspect inputs. + Must return a dictionary of (possibly modified) inputs. + """ return inputs def after_call( self, outputs: Any, ) -> Any: - """Transform and inspect outputs after the function call.""" + """ + Hook executed after the wrapped function returns. + Transform and/or inspect outputs. + Must return the (possibly modified) output value. + """ return outputs @@ -283,16 +282,17 @@ def after_call( ) if not is_list_str(command) and not is_list_list_str(command): - raise ValueError( + msg = ( "Invalid command format: expected a list of strings or a " - "list of list of string for multiple commands." + "list of list of string for multiple commands.\n" ) + msg += pprint.pformat(command) + raise ValueError(msg) commands = [command] if is_list_str(command) else command + for cmd in commands: print_command(" ".join(cmd)) - - if not dryrun: - for cmd in commands: + if not dryrun: run_command(cmd) for item in outputs or []: @@ -602,13 +602,16 @@ class OutputdirHook(Hook): ---------- plotting : bool If True, add a ``figures`` upper level directory in the output - directory. Default False. + directory. + Default False. quality_check : bool If True, add a ``quality_check`` upper level directory in the output - directory. Default False. + directory. + Default False. morphometry : bool If True, add a ``morphometry`` upper level directory in the output - directory. Default False. + directory. + Default False. Examples -------- @@ -706,8 +709,8 @@ class LogRuntimeHook(Hook): Log runtime metadata and input/output details of a function call. This hook uses an ``RSTReport`` instance to record metadata about the - execution of the decorated function. It captures the follwoing - informations: + execution of the decorated function. It captures the following + information: - the function's name, module, and docstring - the input arguments passed to the function @@ -722,9 +725,19 @@ class LogRuntimeHook(Hook): Parameters ---------- title : str | None - A title to display. Default None. + A title to display. + Default None. + clear : bool + If True, the `RSTReport` will be empty. + Default False. bunched : bool - Return a bunch object with a default 'outputs' key. Default True. + Return a bunch object with a default 'outputs' key. + Default True. + parent : bool + Indicates that at least one OutputdirHook parameter has been set to + True. When enabled, the parent output directory is included in the + interface logging mechanism. + Default False. Notes ----- @@ -778,10 +791,14 @@ class LogRuntimeHook(Hook): def __init__( self, title: str | None = None, + clear: bool = False, bunched: bool = True, + parent: bool = False, ) -> None: self.title = title + self.clear = clear self.bunched = bunched + self.parent = parent def before_call( self, @@ -809,9 +826,10 @@ def before_call( comma-separated strings into lists. """ report = RSTReport( - reloadable=True, + reloadable=not self.clear, increment=True, ) + if self.title is not None: print_title(f"{self.title}...") trace = trace_module_calls() @@ -824,6 +842,52 @@ def before_call( report.register(self.identifier, "trace", trace) report.register(self.identifier, "inputs", Bunch(**inputs)) self.start = datetime.datetime.now() + + if func.__module__.startswith("brainprep.interfaces"): + cmd = [ + "brainprep", + "interface", + func.__qualname__.replace("_", "-") + ] + for name, val in inputs.items(): + if self.parent and name == "output_dir": + val = val.parent + cmd.extend([ + f"-{name.replace('_', '-')}", + ( + ",".join( + "_".join( + f"{key}-{val}" + for key, val in item.items() + ) + for item in val + ) + if name == "entities" and isinstance(val, list) + else "_".join( + f"{key}-{val}" + for key, val in val.items() + ) + if name == "entities" + else ",".join( + str(obj) + for obj in val + ) + if isinstance(val, list) + else str(val) + ), + ]) + config = DEFAULT_OPTIONS.copy() + config.update( + brainprep_options.get() + ) + for name, val in config.items(): + cmd.extend([ + f"-{name.replace('_', '-')}", + str(val), + ]) + print_command(" ".join(cmd)) + report.register_command(" ".join(cmd)) + return inputs def after_call( @@ -961,6 +1025,11 @@ def after_call( ) report_file.parent.mkdir(parents=True, exist_ok=True) report.save_as_rst(report_file) + report.save_commands_as_rst( + report_file.with_name( + report_file.name.replace("report_", "commands_") + ) + ) return outputs @@ -1048,3 +1117,71 @@ def step( for plug in hooks: outputs = plug.after_call(outputs) return outputs + + +class SignatureHook(Hook): + """ + Decorator that prints which function is called, its arguments and + execution time. + + Examples + -------- + >>> from brainprep.decorators import step, SignatureHook + + >>> @step( + ... hooks=[SignatureHook()] + ... ) + ... def add(a, b): + ... '''Adds two numbers.''' + ... return a + b + + >>> result = add(3, 5) # doctest: +SKIP + """ + + def before_call( + self, + func: Callable, + inputs: dict[str, Any], + ) -> dict[str, Any]: + """ + Display start information. + + Parameters + ---------- + func : Callable + The function to be decorated. + inputs : dict[str, Any] + Positional and keyword arguments passed to `func`. + + Returns + ------- + inputs : dict[str, Any] + Unchanged positional and keyword arguments passed to `func`. + """ + self._start = time.perf_counter() + if inputs: + args_lines = ",\n".join( + f" {k}={v!r}" for k, v in inputs.items() + ) + signature = ( + f"{func.__module__}.{func.__qualname__}(\n{args_lines},\n)" + ) + else: + signature = f"{func.__module__}.{func.__qualname__}()" + + print_call("_" * 80) + print_call(f"[call] {signature}") + return inputs + + def after_call( + self, + outputs: Any, + ) -> Any: + """ + Display end information. + """ + end = time.perf_counter() + duration = end - self._start + msg = f"{duration:.2f}s, {duration / 60:.2f}min" + print_call("_" * max(0, 80 - len(msg)) + msg) + return outputs diff --git a/brainprep/interfaces/__init__.py b/brainprep/interfaces/__init__.py index 929397e7..b76e9fcd 100644 --- a/brainprep/interfaces/__init__.py +++ b/brainprep/interfaces/__init__.py @@ -10,43 +10,57 @@ Module that implements interfaces. """ +from .amico import ( + noddifit, +) from .ants import ( biasfield, ) from .cat12 import ( cat12vbm_morphometry, - cat12vbm_wf, - write_catbatch, + cat12vbm_workflow, + writebatch, ) from .fmriprep import ( - fmriprep_wf, - func_vol_connectivity, + fmri_connectivity, + fmriprep_workflow, ) from .freesurfer import ( brainmask, - freesurfer_command_status, - freesurfer_features_summary, - freesurfer_tissues, - fsaveragesym_projection, - fsaveragesym_surfreg, - localgi, - mgz_to_nii, + convertmgz, + freesurfer_status, nextbrain, reconall, + reconall_localgi, reconall_longitudinal, + reconall_projection, + reconall_summary, + reconall_surfreg, + reconall_tissues, ) from .fsl import ( - affine, + align, applyaffine, applymask, deface, + dtifit, reorient, scale, ) +from .geolab import ( + geolab_parcellation, +) +from .morphologist import ( + morphologist_morphometry, + morphologist_workflow, +) from .mriqc import ( group_level_qa, subject_level_qa, ) +from .mrtrix3 import ( + dwiprep, +) from .plotting import ( plot_brainparc, plot_defacing_mosaic, @@ -55,67 +69,92 @@ plot_pca, ) from .qualcheck import ( - euler_numbers, + eulernums, fmriprep_metrics, - incremental_pca, - mask_overlap, - mean_correlation, + maskdiff, + maskoverlap, + meancorr, mriqc_metrics, network_entropy, + pca, + sulcirec_metrics, vbm_metrics, ) +from .reporting import ( + parse_defacing, + parse_qa, + parse_quasiraw, +) +from .synb0disco import ( + synthb0, +) +from .tractseg import ( + tractseg_parcellation, +) from .utils import ( anonfile, copyfiles, - maskdiff, + htmlmin, movedir, ungzfile, write_uuid_mapping, ) __all__ = [ - "affine", + "align", "anonfile", "applyaffine", "applymask", "biasfield", "brainmask", "cat12vbm_morphometry", - "cat12vbm_wf", + "cat12vbm_workflow", + "convertmgz", "copyfiles", "deface", - "euler_numbers", + "dtifit", + "dwiprep", + "eulernums", + "fmri_connectivity", "fmriprep_metrics", - "fmriprep_wf", - "freesurfer_command_status", - "freesurfer_features_summary", - "freesurfer_tissues", - "fsaveragesym_projection", - "fsaveragesym_surfreg", - "func_vol_connectivity", + "fmriprep_workflow", + "freesurfer_status", + "geolab_parcellation", "group_level_qa", - "incremental_pca", - "localgi", - "mask_overlap", + "htmlmin", "maskdiff", - "mean_correlation", - "mgz_to_nii", + "maskoverlap", + "meancorr", + "morphologist_workflow", "movedir", "mriqc_metrics", "network_entropy", "nextbrain", + "noddifit", + "parse_defacing", + "parse_qa", + "parse_quasiraw", + "pca", "plot_brainparc", "plot_defacing_mosaic", "plot_histogram", "plot_network", "plot_pca", "reconall", + "reconall_localgi", "reconall_longitudinal", + "reconall_projection", + "reconall_summary", + "reconall_surfreg", + "reconall_tissues", "reorient", "scale", "subject_level_qa", + "sulcirec_metrics", + "synthb0", + "tractseg_parcellation", "ungzfile", "vbm_metrics", - "write_catbatch", "write_uuid_mapping", + "writebatch", ] diff --git a/brainprep/interfaces/amico.py b/brainprep/interfaces/amico.py new file mode 100644 index 00000000..3452070c --- /dev/null +++ b/brainprep/interfaces/amico.py @@ -0,0 +1,162 @@ +########################################################################## +# NSAp - Copyright (C) CEA, 2021 - 2026 +# Distributed under the terms of the CeCILL-B license, as published by +# the CEA-CNRS-INRIA. Refer to the LICENSE file or to +# http://www.cecill.info/licences/Licence_CeCILL-B_V1-en.html +# for details. +########################################################################## + +""" +Amico functions. +""" + +import os +import shutil + +import numpy as np + +from ..decorators import ( + CoerceparamsHook, + LogRuntimeHook, + OutputdirHook, + PythonWrapperHook, + SignatureHook, + step, +) +from ..typing import ( + Directory, + File, +) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + bunched=False + ), + PythonWrapperHook(), + SignatureHook(), + ] +) +def noddifit( + dwi_file: File, + mask_file: File, + workspace_dir: Directory, + output_dir: Directory, + entities: dict, + dryrun: bool = False, + ) -> tuple[File]: + """ + NODDI model fitting. + + This function compute diffusion NODDI microstructural maps using AMICO + for a single subject. + + NODDI fitting is performed with default parameters and estimates neurite + density and orientation dispersion using a multi-compartment model, + producing maps such as Neurite Density Index (NDI), Free Water Fraction + (FWF), and Orientation Dispersion Index (ODI). + + Parameters + ---------- + dwi_file : File + Path to the preprocessed diffusion weighted image file of one subject. + mask_file : File + Path to the associated brain image file. + workspace_dir: Directory + Working directory with the workspace of the current processing. + output_dir : Directory + Directory where the reoriented image will be saved. + entities : dict + A dictionary of parsed BIDS entities including modality. + dryrun : bool + If True, skip actual computation and file writing. + Default False. + + Returns + ------- + outputs : tuple[File] + - config_file : File - AMICO internal configuration. + - ndi_file : File - Neurite Density Index map. + - fwf_file : File - Free Water Fraction map. + - odi_file : File - Orientation Dispersion Index map. + + Raises + ------ + RuntimeError + If AMICO is not installed. + """ + output_dir_ = output_dir / "maps" + output_dir_.mkdir(parents=True, exist_ok=True) + subject, session = entities["sub"], entities["ses"] + basename = f"sub-{subject}_ses-{session}_desc-noddi" + names = [ + "config.pickle", + "fit_NDI.nii.gz", + "fit_FWF.nii.gz", + "fit_ODI.nii.gz", + ] + outputs = [] + for name in names: + target_file = ( + output_dir_ / + f"{basename}_{name.replace('fit_', '')}" + ) + outputs.append(target_file) + if dryrun: + return outputs + + os.environ["DIPY_HOME"] = str(workspace_dir / "dipy") + try: + import amico + except ImportError as exc: + raise RuntimeError( + "AMICO is required for NODDI estimation but is not installed." + ) from exc + + amico.setup() + amico_work_dir = workspace_dir / "AMICO" + amico_work_dir.mkdir(parents=True, exist_ok=True) + ae = amico.Evaluation( + output_path=amico_work_dir, + ) + amico.util.fsl2scheme( + bvalsFilename=str(dwi_file).replace(".nii.gz", ".bval"), + bvecsFilename=str(dwi_file).replace(".nii.gz", ".bvec"), + schemeFilename=( + amico_work_dir / + "NODDI_protocol.scheme" + ), + ) + ae.load_data( + dwi_filename=dwi_file, + scheme_filename=( + amico_work_dir / + "NODDI_protocol.scheme" + ), + mask_filename=mask_file, + b0_thr=0, + ) + + ae.set_model("NODDI") + ae.model.set( + dPar=1.7E-3, + dIso=3.0E-3, + IC_VFs=np.linspace(0.1, 0.99, 12), + IC_ODs=np.hstack((np.array([0.03, 0.06]), + np.linspace(0.09, 0.99, 10))), + ) + ae.generate_kernels(regenerate=True) + ae.load_kernels() + + ae.fit() + ae.save_results() + + subject, session = entities["sub"], entities["ses"] + for target_file, name in zip(outputs, names, strict=True): + source_file = amico_work_dir / name + shutil.copy2(source_file, target_file) + + return outputs diff --git a/brainprep/interfaces/ants.py b/brainprep/interfaces/ants.py index 7bea45e0..24b464c6 100644 --- a/brainprep/interfaces/ants.py +++ b/brainprep/interfaces/ants.py @@ -16,6 +16,7 @@ CommandLineWrapperHook, LogRuntimeHook, OutputdirHook, + SignatureHook, step, ) from ..typing import ( @@ -32,13 +33,15 @@ bunched=False ), CommandLineWrapperHook(), + SignatureHook(), ] ) def biasfield( image_file: File, mask_file: File, output_dir: Directory, - entities: dict) -> tuple[list[str], tuple[File]]: + entities: dict, + quick: bool = False) -> tuple[list[str], tuple[File]]: """ Bias field correction of a BIDS-compliant anatomical image using ANTs's `N4BiasFieldCorrection`. @@ -53,6 +56,10 @@ def biasfield( Directory where the reoriented image will be saved. entities : dict A dictionary of parsed BIDS entities including modality. + quick : bool + Increased shrink factor from `1` to `4`, which downsamples the image + before estimating the bias field. + Default False. Returns ------- @@ -71,7 +78,7 @@ def biasfield( "N4BiasFieldCorrection", "-d", "3", "-i", str(image_file), - "-s", "1", + "-s", "4" if quick else "1", "-b", "[1x1x1,3]", "-c", "[50x50x50x50,0.001]", "-t", "[0.15,0.01,200]", diff --git a/brainprep/interfaces/cat12.py b/brainprep/interfaces/cat12.py index 56cea40b..cd155095 100644 --- a/brainprep/interfaces/cat12.py +++ b/brainprep/interfaces/cat12.py @@ -27,6 +27,7 @@ LogRuntimeHook, OutputdirHook, PythonWrapperHook, + SignatureHook, step, ) from ..typing import ( @@ -50,13 +51,15 @@ bunched=False ), CommandLineWrapperHook(), + SignatureHook(), ] ) -def cat12vbm_wf( +def cat12vbm_workflow( t1_files: list[File], batch_file: File, output_dir: Directory, - entities: list[dict]) -> tuple[list[str], tuple[File | list[File]]]: + entities: list[dict], + ) -> tuple[list[str], tuple[File | list[File]]]: """ Compute VBM prep-processing using CAT12. @@ -85,17 +88,26 @@ def cat12vbm_wf( cat12_file = opts.get("cat12_file", DEFAULT_OPTIONS["cat12_file"]) spm12_dir = opts.get("spm12_dir", DEFAULT_OPTIONS["spm12_dir"]) matlab_dir = opts.get("matlab_dir", DEFAULT_OPTIONS["matlab_dir"]) + longitudinal = len(t1_files) > 1 output_dirs = [ output_dir / f"ses-{info['ses']}" for info in entities ] gm_files = [ - trg_dir / "mri" / f"mwp1{im_file.name.replace('.gz', '')}" + trg_dir / "mri" / ( + f"mwp1r{im_file.name.replace('.gz', '')}" + if longitudinal + else f"mwp1{im_file.name.replace('.gz', '')}" + ) for im_file, trg_dir in zip(t1_files, output_dirs, strict=True) ] qc_files = [ - trg_dir / "report" / f"catreport_{im_file.name.replace('.gz', '')}" + trg_dir / "report" / ( + f"catreport_r{im_file.name.replace('nii.gz', 'pdf')}" + if longitudinal + else f"catreport_{im_file.name.replace('nii.gz', 'pdf')}" + ) for im_file, trg_dir in zip(t1_files, output_dirs, strict=True) ] @@ -117,14 +129,16 @@ def cat12vbm_wf( bunched=False ), PythonWrapperHook(), + SignatureHook(), ] ) -def write_catbatch( +def writebatch( t1_files: list[File], output_dir: Directory, entities: list[dict], model_long: int = 1, - dryrun: bool = False) -> tuple[File]: + dryrun: bool = False, + ) -> tuple[File]: """ Generate CAT12 batch file. @@ -139,9 +153,11 @@ def write_catbatch( image file. model_long : int Longitudinal model choice:1 short time (weeks), 2 long time (years) - between images sessions. Default 1. + between images sessions. + Default 1. dryrun : bool - If True, skip actual computation and file writing. Default False. + If True, skip actual computation and file writing. + Default False. Returns ------- @@ -176,7 +192,6 @@ def write_catbatch( f"ses-{entities[0]['ses']}" / f"cat12vbm_matlabbatch_run-{entities[0]['run']}.m" ) - batch_file.parent.mkdir(parents=True, exist_ok=True) template_batch = ( Path(__file__).parent.parent / "resources" / @@ -186,6 +201,9 @@ def write_catbatch( output_dir / f"ses-{info['ses']}" for info in entities ] + batch_file.parent.mkdir(parents=True, exist_ok=True) + for output_dir in output_dirs: + output_dir.mkdir(exist_ok=True) unzip_t1_files = [ trg_dir / im_file.name.replace(".gz", "") for im_file, trg_dir in zip(t1_files, output_dirs, strict=True) @@ -221,14 +239,17 @@ def write_catbatch( morphometry=True ), LogRuntimeHook( - bunched=False + bunched=False, + parent=True ), PythonWrapperHook(), + SignatureHook(), ] ) def cat12vbm_morphometry( output_dir: Directory, - dryrun: bool = False) -> list[File]: + dryrun: bool = False, + ) -> list[File]: """ Extract ROI-based morphometry features and global tissue volumes from CAT12 VBM outputs. @@ -245,7 +266,8 @@ def cat12vbm_morphometry( output_dir : Directory Working directory containing the outputs. dryrun : bool - If True, skip actual computation and file writing. Default False. + If True, skip actual computation and file writing. + Default False. Returns ------- diff --git a/brainprep/interfaces/fmriprep.py b/brainprep/interfaces/fmriprep.py index ba0cee07..e942fc14 100644 --- a/brainprep/interfaces/fmriprep.py +++ b/brainprep/interfaces/fmriprep.py @@ -11,10 +11,10 @@ fMRIprep functions. """ -import glob import json import os import shutil +import tempfile from pathlib import Path import numpy as np @@ -33,6 +33,7 @@ LogRuntimeHook, OutputdirHook, PythonWrapperHook, + SignatureHook, step, ) from ..typing import ( @@ -40,7 +41,8 @@ File, ) from ..utils import ( - parse_bids_keys, + print_warn, + sbref_from_file, sidecar_from_file, ) @@ -53,9 +55,10 @@ bunched=False ), CommandLineWrapperHook(), + SignatureHook(), ] ) -def fmriprep_wf( +def fmriprep_workflow( t1_file: File, func_files: list[File], dataset_description_file: File, @@ -106,14 +109,33 @@ def fmriprep_wf( Notes ----- - Creates BIDS subject specific working directory using copy in - 'rawdata'. + 'rawdata' folder. + - Create FreeSurfer subject directory using copy in 'freesurfer' folder. + - To avoid paths that are too long and not allowed by FreeSurfer, use the + '/tmp' directory. To prevent data leakage, please bind '/tmp' to a + secure location. - Store intermediate pre-processing outputs in 'work'. + - Use as many CPUs as available. """ rawdata_dir = workspace_dir / "rawdata" anat_dir = rawdata_dir / "anat" func_dir = rawdata_dir / "func" work_dir = workspace_dir / "work" - for path in (anat_dir, func_dir, work_dir): + work_freesurfer_dir = workspace_dir / "freesurfer" + tmp_dir = Path(tempfile.TemporaryDirectory().name) + work_tmp_dir = tmp_dir / "work" + print_warn( + "To avoid paths that are too long and not allowed by FreeSurfer, we " + "use the '/tmp' directory. To prevent data leakage, please bind " + "'/tmp' to a secure location. Current temporary directory: " + f"{tmp_dir}." + ) + for path in ( + anat_dir, + func_dir, + work_freesurfer_dir, + work_tmp_dir, + ): path.mkdir(parents=True, exist_ok=True) subject, session = entities["sub"], entities["ses"] fshome_dir = os.getenv("FREESURFER_HOME") @@ -122,20 +144,38 @@ def fmriprep_wf( "You must define the 'FREESURFER_HOME' environment variable." ) fshome_dir = Path(fshome_dir) + fssubject_dir = freesurfer_dir / f"sub-{subject}" + fssubject_local_dir = work_freesurfer_dir / f"sub-{subject}_ses-{session}" for source_file, target_dir in zip( [t1_file, *func_files], [anat_dir] + [func_dir] * len(func_files), strict=True): + if (target_dir / source_file.name).is_file(): + print_warn( + "Skipping copy. Target directory already contains source " + f"data: {target_dir / source_file.name}" + ) + continue sidecar_source_file = sidecar_from_file(source_file) - if not (target_dir / source_file.name).is_file(): + sbref_source_file = sbref_from_file(source_file) + shutil.copy( + source_file, + target_dir / source_file.name, + ) + shutil.copy( + sidecar_source_file, + target_dir / sidecar_source_file.name, + ) + if sbref_source_file is not None: + sidecar_sbref_source_file = sidecar_from_file(sbref_source_file) shutil.copy( - source_file, - target_dir / source_file.name, + sbref_source_file, + target_dir / sbref_source_file.name, ) shutil.copy( - sidecar_source_file, - target_dir / sidecar_source_file.name, + sidecar_sbref_source_file, + target_dir / sidecar_sbref_source_file.name, ) if not (rawdata_dir / dataset_description_file.name).is_file(): shutil.copy( @@ -174,10 +214,18 @@ def fmriprep_wf( ( fmriprep_dir / "func" / - f"{basename}_space-{template}_den-91k_bold.dtseries.nii", + f"{basename}_hemi-L_space-fsnative_bold.func.gii", + ), + ( + fmriprep_dir / + "func" / + f"{basename}_hemi-R_space-fsnative_bold.func.gii", + ), + ( + fmriprep_dir / + "func" / + f"{basename}_space-fsLR_den-91k_bold.dtseries.nii", ) - for template in ("fsnative", - "fsLR") ] confounds_file = ( fmriprep_dir / @@ -191,30 +239,50 @@ def fmriprep_wf( confounds_file, ]) - command = [ - "fmriprep", - str(rawdata_dir), - str(output_dir.parent.parent), - "participant", - "--fs-subjects-dir", str(freesurfer_dir), - "--work-dir", str(work_dir), - "--n-cpus", str(os.cpu_count()), - "--stop-on-first-crash", - "--fs-license-file", str(fshome_dir / "license.txt"), - "--skip-bids-validation", - "--fs-no-reconall", - "--fs-no-resume", - "--force", "bbr", "syn-sdc", - "--no-msm", - "--cifti-output", "91k", - "--output-spaces", - "T1w", "MNI152NLin2009cAsym", "MNI152NLin2009cAsym:res-2", - "fsnative", "fsLR", - "--ignore", "slicetiming", - "--participant-label", subject, + commands = [ + [ + "cp", + "-r", + str(fssubject_dir), + str(fssubject_local_dir), + ], + [ + "fmriprep", + str(rawdata_dir), + str(output_dir.parent.parent), + "participant", + "--notrack", + "--skip-bids-validation", + "--stop-on-first-crash", + "--n-cpus", str(os.cpu_count()), + "--fs-license-file", str(fshome_dir / "license.txt"), + "--fs-subjects-dir", str(work_freesurfer_dir), + "--work-dir", str(work_tmp_dir), + "--fs-no-resume", + "--force", "bbr", + "syn-sdc", + "--no-msm", + "--cifti-output", "91k", + "--output-spaces", + "T1w", "MNI152NLin2009cAsym", "MNI152NLin2009cAsym:res-2", + "fsnative", "fsLR", + "--ignore", "slicetiming", + "--participant-label", subject, + ], + [ + "cp", + "-r", + str(work_tmp_dir), + str(work_dir.parent), + ], + [ + "rm", + "-r", + str(tmp_dir), + ], ] - return command, (rfmri_outputs, qc_file) + return commands, (rfmri_outputs, qc_file) @step( @@ -225,9 +293,10 @@ def fmriprep_wf( bunched=False ), PythonWrapperHook(), + SignatureHook(), ] ) -def func_vol_connectivity( +def fmri_connectivity( fmri_rest_image_file: File, mask_file: File, counfounds_file: File, diff --git a/brainprep/interfaces/freesurfer.py b/brainprep/interfaces/freesurfer.py index 32502b7a..8fa55054 100644 --- a/brainprep/interfaces/freesurfer.py +++ b/brainprep/interfaces/freesurfer.py @@ -20,12 +20,17 @@ import numpy as np import pandas as pd +from ..config import ( + DEFAULT_OPTIONS, + brainprep_options, +) from ..decorators import ( CoerceparamsHook, CommandLineWrapperHook, LogRuntimeHook, OutputdirHook, PythonWrapperHook, + SignatureHook, step, ) from ..typing import ( @@ -33,7 +38,6 @@ File, ) from ..utils import ( - parse_bids_keys, print_info, print_warn, ) @@ -47,12 +51,15 @@ bunched=False ), CommandLineWrapperHook(), + SignatureHook(), ] ) def brainmask( image_file: File, output_dir: Directory, - entities: dict) -> tuple[list[str], tuple[File]]: + entities: dict, + legacy: bool = False, + ) -> tuple[list[str], tuple[File]]: """ Skull-strip a BIDS-compliant anatomical image using FreeSurfer's `mri_synthstrip`. @@ -70,31 +77,44 @@ def brainmask( Directory where the reoriented image will be saved. entities : dict A dictionary of parsed BIDS entities including modality. + legacy : bool + With old version the '-f: BG fill value, defaults to min(image.min, 0)' + is not available, the legacy mode adapt the command line accordingly. + Default False. Returns ------- command : list[str] Skull-stripping command-line. outputs : tuple[File] - - mask_file : File - Skull-stripped brain image file. + -brain_file : File - Skull-stripped brain image file. + -mask_file : File - Binary brain mask image file. References ---------- .. footbibliography:: """ - basename = "sub-{sub}_ses-{ses}_run-{run}_mod-{mod}_brainmask".format( + basename = "sub-{sub}_ses-{ses}_run-{run}_mod-{mod}".format( **entities) - mask_file = output_dir / f"{basename}.nii.gz" + brain_file = output_dir / f"{basename}_brain.nii.gz" + mask_file = output_dir / f"{basename}_brainmask.nii.gz" command = [ "mri_synthstrip", "-i", str(image_file), + "-o", str(brain_file), "-m", str(mask_file), "--no-csf", ] + if not legacy: + command.extend( + [ + "-f", "0", + ] + ) - return command, (mask_file, ) + return command, (brain_file, mask_file, ) @step( @@ -105,6 +125,7 @@ def brainmask( bunched=False ), CommandLineWrapperHook(), + SignatureHook(), ] ) def reconall( @@ -113,7 +134,8 @@ def reconall( entities: dict, t2_file: File | None = None, flair_file: File | None = None, - resume: bool = False) -> tuple[list[str], tuple[File]]: + resume: bool = False, + ) -> tuple[list[str], tuple[File]]: """ Brain parcellation using FreeSurfer's `recon-all`. @@ -139,7 +161,8 @@ def reconall( Default None. resume : bool If True, try to resume `recon-all`. This option is particularly useful - when a custom segmentation is used in `recon-all`. Default False. + when a custom segmentation is used in `recon-all`. + Default False. Returns ------- @@ -188,12 +211,14 @@ def reconall( bunched=False ), CommandLineWrapperHook(), + SignatureHook(), ] ) def reconall_longitudinal( workspace_dir: Directory, output_dir: Directory, - entities: dict) -> tuple[list[str], tuple[File | list[File]]]: + entities: dict, + ) -> tuple[list[list[str]], tuple[File | list[File]]]: """ Longitudinal brain parcellation using FreeSurfer's `recon-all`. @@ -201,8 +226,8 @@ def reconall_longitudinal( and that the results are stored in one subject directory per timepoint, this function will: - 1) generate a template for this subject using `recon-all`. - 2) parcellation refinements using `recon-all` and the new generated + 1) Generate a template for this subject using `recon-all`. + 2) Parcellation refinements using `recon-all` and the new generated template. Parameters @@ -217,8 +242,8 @@ def reconall_longitudinal( Returns ------- - command : list[str] - Brain parcellation command-line. + commands : list[list[str]] + Brain parcellation command-lines. outputs : tuple[File | list[File]] - log_template_file : File - Generated log file for the template creation step. @@ -309,12 +334,14 @@ def reconall_longitudinal( bunched=False ), PythonWrapperHook(), + SignatureHook(), ] ) -def freesurfer_command_status( +def freesurfer_status( log_file: File, command: str, - dryrun: bool = False) -> None: + dryrun: bool = False, + ) -> None: """ Check the status of a FreeSurfer `recon-all` process from its log file. @@ -326,7 +353,8 @@ def freesurfer_command_status( The name of the command-line that produces the log file - used as a selector to define the success phrase. dryrun : bool - If True, skip actual computation and file writing. Default False. + If True, skip actual computation and file writing. + Default False. Raises ------ @@ -346,39 +374,40 @@ def freesurfer_command_status( - This function raises exceptions to signal failure or ambiguity, and does not return any value. """ - if not dryrun: + if dryrun: + return - if not log_file.is_file(): - raise FileNotFoundError(f"Log file not found: {log_file}") + if not log_file.is_file(): + raise FileNotFoundError(f"Log file not found: {log_file}") - if command == "recon-all": - success_phrase = "finished without error" - elif command == "xhemireg": - success_phrase = "xhemireg done" - else: - raise ValueError( - "Command line not supported." - ) - error_keywords = ["ERROR:", "FATAL:"] + if command == "recon-all": + success_phrase = "finished without error" + elif command == "xhemireg": + success_phrase = "xhemireg done" + else: + raise ValueError( + "Command line not supported." + ) + error_keywords = ["ERROR:", "FATAL:"] - lines = log_file.read_text().splitlines() - last_lines = lines[-20:] + lines = log_file.read_text().splitlines() + last_lines = lines[-20:] - if any(success_phrase in line for line in last_lines): - return - errors = [ - line - for line in lines - if any(err in line for err in error_keywords) - ] - if errors: - raise RuntimeError( - f"Recon-all failed. Found {len(errors)} error(s):\n" + - "\n".join(errors) - ) + if any(success_phrase in line for line in last_lines): + return + errors = [ + line + for line in lines + if any(err in line for err in error_keywords) + ] + if errors: raise RuntimeError( - "Recon-all status unclear. No success or error markers found." + f"Recon-all failed. Found {len(errors)} error(s):\n" + + "\n".join(errors) ) + raise RuntimeError( + "Recon-all status unclear. No success or error markers found." + ) @step( @@ -389,11 +418,13 @@ def freesurfer_command_status( bunched=False ), CommandLineWrapperHook(), + SignatureHook(), ] ) -def localgi( +def reconall_localgi( output_dir: Directory, - entities: dict) -> tuple[list[str], tuple[File]]: + entities: dict, + ) -> tuple[list[str], tuple[File]]: """ Local Gyrification Index (localGI or lGI). @@ -435,19 +466,10 @@ def localgi( return command, (left_lgi_file, right_lgi_file) -@step( - hooks=[ - CoerceparamsHook(), - OutputdirHook(), - LogRuntimeHook( - bunched=False - ), - CommandLineWrapperHook(), - ] -) def surfreg( output_dir: Directory, - entities: dict) -> tuple[list[str], tuple[File]]: + entities: dict, + ) -> tuple[list[list[str]], tuple[File]]: """ Surface-based registration to `fsaverage_sym` symmetric template. @@ -466,8 +488,8 @@ def surfreg( Returns ------- - command : list[str] - Registration command-line. + commands : list[list[str]] + Registration command-lines. outputs : tuple[File] - left_reg_file : File - Left hemisphere registered to `fsaverage_sym` symmetric template. @@ -511,19 +533,10 @@ def surfreg( return commands, (left_reg_file, right_reg_file) -@step( - hooks=[ - CoerceparamsHook(), - OutputdirHook(), - LogRuntimeHook( - bunched=False - ), - CommandLineWrapperHook(), - ] -) def xhemireg( output_dir: Directory, - entities: dict) -> tuple[list[str], tuple[File]]: + entities: dict, + ) -> tuple[list[str], tuple[File]]: """ Symmetric mapping of the right hemisphere to the left hemisphere space. within the subject's own space. @@ -564,11 +577,14 @@ def xhemireg( LogRuntimeHook( bunched=False ), + CommandLineWrapperHook(), + SignatureHook(), ] ) -def fsaveragesym_surfreg( +def reconall_surfreg( output_dir: Directory, - entities: dict) -> tuple[File, File]: + entities: dict, + ) -> tuple[list[list[str]], tuple[File]]: """ Interhemispheric surface-based registration using the `fsaverage_sym` template, and FreeSurfer's `xhemireg` and `surfreg`. @@ -593,11 +609,14 @@ def fsaveragesym_surfreg( Returns ------- - left_reg_file : File - Left hemisphere registered to `fsaverage_sym` symmetric template. - right_reg_file : File - Right hemisphere registered to `fsaverage_sym` symmetric template - via xhemi. + commands : list[list[str]] + Registration command-lines. + outputs : tuple[File] + - left_reg_file : File - Left hemisphere registered to `fsaverage_sym` + symmetric template. + - right_reg_file : File - Right hemisphere registered to + `fsaverage_sym` symmetric template via xhemi. + - log_file : File - Generated log file. Notes ----- @@ -615,36 +634,28 @@ def fsaveragesym_surfreg( subject = f"run-{entities['run']}" os.environ["SUBJECTS_DIR"] = str(output_dir) - _left_log_file, right_log_file = xhemireg( + cmd, (_left_log_file, right_log_file) = xhemireg( output_dir, entities, ) - freesurfer_command_status( - right_log_file, - command="xhemireg", - ) - left_reg_file, right_reg_file = surfreg( + cmds, (left_reg_file, right_reg_file) = surfreg( output_dir, entities, ) + commands = [ + cmd, + *cmds, + ] - return (left_reg_file, right_reg_file) + return commands, (left_reg_file, right_reg_file, right_log_file) -@step( - hooks=[ - CoerceparamsHook(), - LogRuntimeHook( - bunched=False - ), - CommandLineWrapperHook(), - ] -) def mris_apply_reg( src_file: File, trg_file: File, srcreg_file: File, - targreg_file: File) -> tuple[list[str], tuple[File]]: + targreg_file: File, + ) -> tuple[list[str], tuple[File]]: """ Apply a surface-based registration to a cortical surface data file. @@ -686,15 +697,16 @@ def mris_apply_reg( LogRuntimeHook( bunched=False ), - PythonWrapperHook(), + CommandLineWrapperHook(), + SignatureHook(), ] ) -def fsaveragesym_projection( +def reconall_projection( left_reg_file: File, right_reg_file: File, output_dir: Directory, entities: dict, - dryrun: bool = False) -> tuple[File]: + ) -> tuple[list[list[str]], tuple[File]]: """ Project the different cortical features to the 'fsaverage_sym' template space using FreeSurfer's `mris_apply_reg`. @@ -718,12 +730,12 @@ def fsaveragesym_projection( FreeSurfer working directory containing all the subjects. entities : dict A dictionary of parsed BIDS entities including modality. - dryrun : bool - If True, skip actual computation and file writing. Default False. Returns ------- - features: tuple[File] + commands : list[list[str]] + Projections command-lines. + outputs : tuple[File] A tuple containing features in the `fsaverage_sym` symmetric template. Each feature file is a MGH file with the suffix "fsaverage_sym". The features are returned in the following order: @@ -751,7 +763,10 @@ def fsaveragesym_projection( "template": template_dir / "surf" / "lh.sphere.reg" } - features = [] + opts = brainprep_options.get() + dryrun = opts.get("dryrun", DEFAULT_OPTIONS["dryrun"]) + + commands, features = [], [] for name in ("thickness", "curv", "area", "pial_lgi", "sulc"): for hemi in ("lh", "rh"): src_feature_file = ( @@ -768,30 +783,26 @@ def fsaveragesym_projection( ) if trg_feature_file.is_file(): print_warn(f"overwrite file: {trg_feature_file}") - mris_apply_reg( - src_feature_file, - trg_feature_file, - reg_map[hemi], - reg_map["template"], + commands.append( + mris_apply_reg( + src_feature_file, + trg_feature_file, + reg_map[hemi], + reg_map["template"], + )[0] + ) + features.append( + trg_feature_file ) - features.append(trg_feature_file) - return tuple(features) + return commands, tuple(features) -@step( - hooks=[ - CoerceparamsHook(), - LogRuntimeHook( - bunched=False - ), - CommandLineWrapperHook(), - ] -) def mri_convert( src_file: File, trg_file: File, - reference_file: File) -> tuple[list[str], tuple[File]]: + reference_file: File, + ) -> tuple[list[str], tuple[File]]: """ Convert a source image and resample it to match the resolution, orientation, and voxel grid of a reference image using FreeSurfer's @@ -831,11 +842,14 @@ def mri_convert( LogRuntimeHook( bunched=False ), + CommandLineWrapperHook(), + SignatureHook(), ] ) -def mgz_to_nii( +def convertmgz( output_dir: Directory, - entities: dict) -> tuple[File]: + entities: dict, + ) -> tuple[list[list[str]], tuple[File]]: """ Convert FreeSurfer images back to original Nifti space. @@ -859,7 +873,9 @@ def mgz_to_nii( Returns ------- - images: tuple[File] + commands : list[list[str]] + Conversion command-lines. + outputs : tuple[File] A tuple containing converted images. The images are returned in the following order: - aparc_aseg_file @@ -873,36 +889,32 @@ def mgz_to_nii( subject = f"run-{entities['run']}" reference_file = output_dir / subject / "mri" / "rawavg.mgz" - images = [] + commands, images = [], [] for name in ("aparc+aseg", "aparc.a2009s+aseg", "aseg", "wm", "rawavg", "ribbon", "brain"): src_file = output_dir / subject / "mri" / f"{name}.mgz" trg_file = output_dir / subject / "mri" / f"{name}.nii.gz" - mri_convert( - src_file, - trg_file, - reference_file, + commands.append( + mri_convert( + src_file, + trg_file, + reference_file, + )[0] + ) + images.append( + trg_file ) - images.append(trg_file) - return tuple(images) + return commands, tuple(images) -@step( - hooks=[ - CoerceparamsHook(), - LogRuntimeHook( - bunched=False - ), - CommandLineWrapperHook(), - ] -) def aparcstats2table( subjects: list[str], session: str, hemi: str, measure: str, - output_dir: Directory) -> tuple[list[str], tuple[File]]: + output_dir: Directory, + ) -> tuple[list[str], tuple[File]]: """ Summarizes the stats data '?h.aparc.stats' for both templates (Desikan & Destrieux) using FreeSurfer's `aparcstats2table`. @@ -965,19 +977,11 @@ def aparcstats2table( return commands, (desikan_stat_file, destrieux_stat_file) -@step( - hooks=[ - CoerceparamsHook(), - LogRuntimeHook( - bunched=False - ), - CommandLineWrapperHook(), - ] -) def asegstats2table( subjects: list[str], session: str, - output_dir: Directory) -> tuple[list[str], tuple[File]]: + output_dir: Directory, + ) -> tuple[list[str], tuple[File]]: """ Summarizes the volumetric data for subcortical brain structures 'aseg.stats' using FreeSurfer's `asegstats2table`. @@ -1020,13 +1024,17 @@ def asegstats2table( morphometry=True ), LogRuntimeHook( - bunched=False + bunched=False, + parent=True ), + CommandLineWrapperHook(), + SignatureHook(), ] ) -def freesurfer_features_summary( +def reconall_summary( workspace_dir: Directory, - output_dir: Directory) -> tuple[File]: + output_dir: Directory, + ) -> tuple[list[list[str]], tuple[File]]: """ Summarizes the generated FreeSurfer features for all subjects. @@ -1053,7 +1061,9 @@ def freesurfer_features_summary( Returns ------- - statfiles: tuple[File] + commands : list[list[str]] + Summary command-lines. + outputs : tuple[File] A tuple containing FreeSurfer summary stats. The data are returned in the following order (the results for each timepoint are stacked): - desikan_stat_lh__file @@ -1094,7 +1104,7 @@ def freesurfer_features_summary( os.symlink(source_dir, target_dir) fs_subjects.setdefault(ses, []).append(f"{sub}_{run}") - summary_files = [] + commands, summary_files = [], [] measures = [ "area", "volume", "thickness", "thicknessstd", "meancurv", "gauscurv", "foldind", "curvind" @@ -1105,40 +1115,44 @@ def freesurfer_features_summary( for hemi in ["lh", "rh"]: for meas in measures: - desikan_stat_file, destrieux_stat_file = aparcstats2table( + cmds, stat_files = aparcstats2table( fs_subjects[ses], ses.replace("ses-", ""), hemi, meas, output_dir, ) - summary_files.extend([ - desikan_stat_file, - destrieux_stat_file, - ]) + commands.extend( + cmds + ) + summary_files.extend( + stat_files + ) - volume_stat_file = asegstats2table( + cmd, volume_stat_file = asegstats2table( fs_subjects[ses], ses.replace("ses-", ""), output_dir, ) - summary_files.append(volume_stat_file) - - # sort by participant_id - output_files = output_dir.glob('*') - for file in output_files: - if file.suffix == ".csv": - sep = ',' - elif file.suffix == ".tsv": - sep = '\t' - else: + commands.append( + cmd + ) + summary_files.append( + volume_stat_file + ) + + output_files = output_dir.glob("*") + seps = {".csv": ",", ".tsv": "\t"} + for table_file in output_files: + if table_file.suffix not in seps: continue - df = pd.read_csv(file, sep=sep) + sep = seps[table_file.suffix] + df = pd.read_csv(table_file, sep=sep) first_col = df.columns[0] df = df.sort_values(by=first_col) - df.to_csv(file, sep=sep, index=False) + df.to_csv(table_file, sep=sep, index=False) - return summary_files + return commands, summary_files @step( @@ -1149,14 +1163,16 @@ def freesurfer_features_summary( bunched=False ), PythonWrapperHook(), + SignatureHook(), ] ) -def freesurfer_tissues( +def reconall_tissues( workspace_dir: Directory, output_dir: Directory, entities: dict, include_cerebellum: bool = False, - dryrun: bool = False) -> tuple[File, File, File, File]: + dryrun: bool = False, + ) -> tuple[File, File, File, File]: """ Binary masks for white matter (WM), gray matter (GM), cerebrospinal fluid (CSF), and whole brain based on FreeSurfer ribbon and wmparc @@ -1192,9 +1208,11 @@ def freesurfer_tissues( entities : dict A dictionary of parsed BIDS entities including modality. include_cerebellum : bool - If False, omit cerebellum and brain stem. Default False. + If False, omit cerebellum and brain stem. + Default False. dryrun : bool - If True, skip actual computation and file writing. Default False. + If True, skip actual computation and file writing. + Default False. Returns ------- @@ -1220,82 +1238,93 @@ def freesurfer_tissues( csf_mask_file = workspace_dir / f"csf_{subject}.nii.gz" brain_mask_file = workspace_dir / f"brain_{subject}.nii.gz" - if not dryrun: + if dryrun: + return ( + wm_mask_file, + gm_mask_file, + csf_mask_file, + brain_mask_file, + ) - ribbon_file = output_dir / subject / "mri" / "ribbon.mgz" - wmparc_file = output_dir / subject / "mri" / "wmparc.mgz" + ribbon_file = output_dir / subject / "mri" / "ribbon.mgz" + wmparc_file = output_dir / subject / "mri" / "wmparc.mgz" - ribbon_wm_structures = [ - 2, 41 + ribbon_wm_structures = [ + 2, 41 + ] + ribbon_gm_structures = [ + 3, 42 + ] + wmparc_cc_structures = [ + 250, 251, 252, 253, 254, 255 + ] + wmparc_csf_structures = [ + 4, 5, 14, 15, 24, 31, 43, 44, 63 + ] + if include_cerebellum: + wmparc_wm_structures = [ + 7, 16, 46 ] - ribbon_gm_structures = [ - 3, 42 + wmparc_gm_structures = [ + 8, 47, 10, 11, 12, 13, 17, 18, 26, 28, 49, 50, + 51, 52, 53, 54, 58, 60 ] - wmparc_cc_structures = [ - 250, 251, 252, 253, 254, 255 + else: + wmparc_wm_structures = [ ] - wmparc_csf_structures = [ - 4, 5, 14, 15, 24, 31, 43, 44, 63 + wmparc_gm_structures = [ + 10, 11, 12, 13, 17, 18, 26, 28, 49, 50, 51, + 52, 53, 54, 58, 60 ] - if include_cerebellum: - wmparc_wm_structures = [ - 7, 16, 46 - ] - wmparc_gm_structures = [ - 8, 47, 10, 11, 12, 13, 17, 18, 26, 28, 49, 50, - 51, 52, 53, 54, 58, 60 - ] - else: - wmparc_wm_structures = [ - ] - wmparc_gm_structures = [ - 10, 11, 12, 13, 17, 18, 26, 28, 49, 50, 51, - 52, 53, 54, 58, 60 - ] - im = nibabel.load(ribbon_file) - ribbon_arr = im.get_fdata() - wmparc_arr = nibabel.load(wmparc_file).get_fdata() + im = nibabel.load(ribbon_file) + ribbon_arr = im.get_fdata() + wmparc_arr = nibabel.load(wmparc_file).get_fdata() - wm_mask_arr = np.logical_and( - np.logical_and( + wm_mask_arr = np.logical_and( + np.logical_and( + np.logical_or( np.logical_or( - np.logical_or( - np.in1d(ribbon_arr, ribbon_wm_structures), - np.in1d(wmparc_arr, wmparc_wm_structures)), - np.in1d(wmparc_arr, wmparc_cc_structures)), - np.logical_not(np.in1d(wmparc_arr, wmparc_csf_structures))), - np.logical_not(np.in1d(wmparc_arr, wmparc_gm_structures)) - ) - csf_mask_arr = np.in1d(wmparc_arr, wmparc_csf_structures) - gm_mask_arr = np.logical_or( - np.in1d(ribbon_arr, ribbon_gm_structures), - np.in1d(wmparc_arr, wmparc_gm_structures) - ) + np.isin(ribbon_arr, ribbon_wm_structures), + np.isin(wmparc_arr, wmparc_wm_structures)), + np.isin(wmparc_arr, wmparc_cc_structures)), + np.logical_not(np.isin(wmparc_arr, wmparc_csf_structures))), + np.logical_not(np.isin(wmparc_arr, wmparc_gm_structures)) + ) + csf_mask_arr = np.isin(wmparc_arr, wmparc_csf_structures) + gm_mask_arr = np.logical_or( + np.isin(ribbon_arr, ribbon_gm_structures), + np.isin(wmparc_arr, wmparc_gm_structures) + ) - wm_mask_arr = np.reshape(wm_mask_arr, ribbon_arr.shape) - gm_mask_arr = np.reshape(gm_mask_arr, ribbon_arr.shape) - csf_mask_arr = np.reshape(csf_mask_arr, ribbon_arr.shape) + wm_mask_arr = np.reshape(wm_mask_arr, ribbon_arr.shape) + gm_mask_arr = np.reshape(gm_mask_arr, ribbon_arr.shape) + csf_mask_arr = np.reshape(csf_mask_arr, ribbon_arr.shape) - brain_mask_arr = np.logical_or( - np.logical_or(wm_mask_arr, gm_mask_arr), - csf_mask_arr - ) + brain_mask_arr = np.logical_or( + np.logical_or(wm_mask_arr, gm_mask_arr), + csf_mask_arr + ) - for arr, out_file in ((wm_mask_arr, wm_mask_file), - (gm_mask_arr, gm_mask_file), - (csf_mask_arr, csf_mask_file), - (brain_mask_arr, brain_mask_file)): - nibabel.save( - nibabel.Nifti1Image( - arr.astype(np.uint8), - im.affine, - dtype=np.uint8, - ), - out_file, - ) + for arr, out_file in ((wm_mask_arr, wm_mask_file), + (gm_mask_arr, gm_mask_file), + (csf_mask_arr, csf_mask_file), + (brain_mask_arr, brain_mask_file)): + nibabel.save( + nibabel.Nifti1Image( + arr.astype(np.uint8), + im.affine, + dtype=np.uint8, + ), + out_file, + ) - return (wm_mask_file, gm_mask_file, csf_mask_file, brain_mask_file) + return ( + wm_mask_file, + gm_mask_file, + csf_mask_file, + brain_mask_file, + ) @step( @@ -1305,12 +1334,14 @@ def freesurfer_tissues( bunched=False ), CommandLineWrapperHook(), + SignatureHook(), ] ) def nextbrain( t1_file: File, output_dir: Directory, - entities: dict) -> tuple[list[str], tuple[File]]: + entities: dict, + ) -> tuple[list[str], tuple[File]]: """ Uses NextBrain probabilistic atlas of the human brain, to segment ~300 distinct ROIs per hemisphere. diff --git a/brainprep/interfaces/fsl.py b/brainprep/interfaces/fsl.py index 09c66e93..910c8871 100644 --- a/brainprep/interfaces/fsl.py +++ b/brainprep/interfaces/fsl.py @@ -11,6 +11,7 @@ FSL functions. """ +import os from pathlib import Path from ..decorators import ( @@ -18,6 +19,7 @@ CommandLineWrapperHook, LogRuntimeHook, OutputdirHook, + SignatureHook, step, ) from ..typing import ( @@ -34,12 +36,14 @@ bunched=False ), CommandLineWrapperHook(), + SignatureHook(), ] ) def reorient( image_file: File, output_dir: Directory, - entities: dict) -> tuple[list[str], tuple[File]]: + entities: dict, + ) -> tuple[list[str], tuple[File]]: """ Reorients a BIDS-compliant anatomical image using FSL's `fslreorient2std`. @@ -59,7 +63,7 @@ def reorient( outputs : tuple[File] - reorient_image_file : File - Reoriented input image file. """ - basename = "sub-{sub}_ses-{ses}_run-{run}_mod-T1w_reorient".format( + basename = "sub-{sub}_ses-{ses}_run-{run}_mod-{mod}_reorient".format( **entities) reorient_image_file = output_dir / f"{basename}.nii.gz" @@ -80,12 +84,14 @@ def reorient( bunched=False ), CommandLineWrapperHook(), + SignatureHook(), ] ) def deface( t1_file: File, output_dir: Directory, - entities: dict) -> tuple[list[str], tuple[File | list[File]]]: + entities: dict, + ) -> tuple[list[str], tuple[File | list[File]]]: """ Defaces a BIDS-compliant T1-weighted anatomical image using FSL's `fsl_deface`. @@ -106,7 +112,8 @@ def deface( outputs : tuple[File | list[File]] - deface_file : File - Defaced input T1w image file. - mask_file : File - Defacing binary mask. - - vol_files : list[File] - Defacing 3d rendering. + - transform_file : File - Affine transformation from original space to + MNI152 space. Raises ------ @@ -123,17 +130,29 @@ def deface( **entities) deface_file = output_dir / f"{basename}.nii.gz" mask_file = output_dir / f"{basename}mask.nii.gz" - - command = [ - "fsl_deface", - str(t1_file), - str(deface_file), - "-d", str(mask_file), - "-f", "0.5", - "-B", + transform_file = output_dir / f"{basename}affine.mat" + + resource_dir = Path(__file__).parent.parent / "resources" + bigfov_transfrom_file = resource_dir / "MNI_BigFov_to_MNI.mat" + + commands = [ + [ + "fsl_deface", + str(t1_file), + str(deface_file), + "-d", str(mask_file), + "-m13", str(transform_file), + "-f", "0.5", + "-B", + ], + [ + "convert_xfm", + "-omat", str(transform_file), + "-concat", str(bigfov_transfrom_file), str(transform_file), + ] ] - return command, (deface_file, mask_file, ) + return commands, (deface_file, mask_file, transform_file) @step( @@ -144,13 +163,15 @@ def deface( bunched=False ), CommandLineWrapperHook(), + SignatureHook(), ] ) def applymask( image_file: File, mask_file: File, output_dir: Directory, - entities: dict) -> tuple[list[str], tuple[File]]: + entities: dict, + ) -> tuple[list[str], tuple[File]]: """ Apply an isotropic resampling transformation to a BIDS-compliant image file using FSL's `fslmaths`. @@ -195,13 +216,16 @@ def applymask( bunched=False ), CommandLineWrapperHook(), + SignatureHook(), ] ) def scale( image_file: File, scale: int, output_dir: Directory, - entities: dict) -> tuple[list[str], tuple[File]]: + entities: dict, + interpolation: str = "spline", + ) -> tuple[list[str], tuple[File]]: """ Apply an isotropic resampling transformation to a BIDS-compliant image file using FSL's `flirt`. @@ -216,6 +240,10 @@ def scale( Directory where the scaled image will be saved. entities : dict A dictionary of parsed BIDS entities including modality. + interpolation: str + The interpolation method: 'trilinear', 'nearestneighbour', 'sinc', or + 'spline'. + Default 'spline'. Returns ------- @@ -235,6 +263,7 @@ def scale( "-in", str(image_file), "-ref", str(image_file), "-applyisoxfm", str(scale), + "-interp", interpolation, "-out", str(scaled_anatomical_file), "-omat", str(transform_file), "-verbose", "1", @@ -251,13 +280,17 @@ def scale( bunched=False ), CommandLineWrapperHook(), + SignatureHook(), ] ) -def affine( +def align( anatomical_file: File, template_file: File, output_dir: Directory, - entities: dict) -> tuple[list[str], tuple[File]]: + entities: dict, + rigid: bool = False, + quick: bool = False, + ) -> tuple[list[str], tuple[File]]: """ Affinely register a BIDS-compliant anatomical image to a template file using FSL's `flirt`. @@ -272,6 +305,15 @@ def affine( Directory where the affine transformation will be saved. entities : dict A dictionary of parsed BIDS entities including modality. + rigid : bool + Estimate a 6 DOF transformation that maintains the original size and + shape of the brain. By default a 9 DOF transformation allows for + additional scaling in the x, y, and z directions, adjusting the size + and shape of the brain during the alignment process. + Default False. + quick : bool + Restricted rotation search range to +/-30° on all three axes. + Default False. Returns ------- @@ -295,11 +337,17 @@ def affine( "-anglerep", "euler", "-bins", "256", "-interp", "trilinear", - "-dof", "9", + "-dof", "6" if rigid else "9", "-out", str(aligned_anatomical_file), "-omat", str(transform_file), "-verbose", "1" ] + if quick: + command += [ + "-searchrx", "-30", "30", + "-searchry", "-30", "30", + "-searchrz", "-30", "30", + ] return command, (aligned_anatomical_file, transform_file) @@ -312,6 +360,7 @@ def affine( bunched=False ), CommandLineWrapperHook(), + SignatureHook(), ] ) def applyaffine( @@ -320,7 +369,8 @@ def applyaffine( transform_file: File, output_dir: Directory, entities: dict, - interpolation: str = "spline") -> tuple[list[str], tuple[File]]: + interpolation: str = "spline", + ) -> tuple[list[str], tuple[File]]: """ Apply an affine transformation to a BIDS-compliant image file using FSL's `flirt`. @@ -339,7 +389,8 @@ def applyaffine( A dictionary of parsed BIDS entities including modality. interpolation: str The interpolation method: 'trilinear', 'nearestneighbour', 'sinc', or - 'spline'. Default 'spline'. + 'spline'. + Default 'spline'. Returns ------- @@ -357,9 +408,82 @@ def applyaffine( "-in", str(image_file), "-ref", str(template_file), "-init", str(transform_file), - "-interp", str(interpolation), + "-interp", interpolation, "-applyxfm", "-out", str(aligned_image_file), ] return command, (aligned_image_file, ) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + bunched=False + ), + CommandLineWrapperHook(), + SignatureHook(), + ] +) +def dtifit( + dwi_file: File, + mask_file: File, + workspace_dir: Directory, + output_dir: Directory, + entities: dict, + ) -> tuple[list[str], tuple[File]]: + """ + DTI model fitting. + + This function prepares the command-line required to compute diffusion + tensor imaging (DTI) metrics using FSL `dtifit` for a single subject. + + DTI estimation is performed using the diffusion tensor model and weighted + least square fitting to derive scalar measures such as fractional + anisotropy (FA), mean diffusivity (MD), and radial diffusivity (RD). + + Parameters + ---------- + dwi_file : File + Path to the preprocessed diffusion weighted image file of one subject. + mask_file : File + Path to the associated brain image file. + workspace_dir: Directory + Working directory with the workspace of the current processing. + output_dir : Directory + Directory where the reoriented image will be saved. + entities : dict + A dictionary of parsed BIDS entities including modality. + + Returns + ------- + command : list[str] + DTI scalar maps computation command-line. + outputs : tuple[File] + - fa_file : File - The DTI fractional anisotropy image file. + - md_file : File - The DTI mean diffusivity image file. + """ + basename = dwi_file.name.removesuffix("_desc-preproc_dwi.nii.gz") + basename += "_desc-dti" + output_dir_ = output_dir / "maps" + output_dir_.mkdir(parents=True, exist_ok=True) + + os.environ["FSLOUTPUTTYPE"] = "NIFTI_GZ" + + command = [ + "dtifit", + "-k", str(dwi_file), + "-m", str(mask_file), + "-r", str(dwi_file).replace(".nii.gz", ".bvec"), + "-b", str(dwi_file).replace(".nii.gz", ".bval"), + "-w", + "--no_tensor", + "-o", str(output_dir_ / basename), + ] + + return command, [ + output_dir_ / f"{basename}_FA.nii.gz", + output_dir_ / f"{basename}_MD.nii.gz", + ] diff --git a/brainprep/interfaces/geolab.py b/brainprep/interfaces/geolab.py new file mode 100644 index 00000000..35f95763 --- /dev/null +++ b/brainprep/interfaces/geolab.py @@ -0,0 +1,199 @@ +########################################################################## +# NSAp - Copyright (C) CEA, 2021 - 2026 +# Distributed under the terms of the CeCILL-B license, as published by +# the CEA-CNRS-INRIA. Refer to the LICENSE file or to +# http://www.cecill.info/licences/Licence_CeCILL-B_V1-en.html +# for details. +########################################################################## + +""" +GeoLab functions. +""" + +import os + +from ..config import ( + DEFAULT_OPTIONS, + brainprep_options, +) +from ..decorators import ( + CoerceparamsHook, + CommandLineWrapperHook, + LogRuntimeHook, + OutputdirHook, + SignatureHook, + step, +) +from ..typing import ( + Directory, + File, +) +from ..utils import ( + print_warn, +) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + bunched=False + ), + CommandLineWrapperHook(), + SignatureHook(), + ] +) +def geolab_parcellation( + tractogram_file: File, + affine_file: File, + invwarp_file: File, + workspace_dir: Directory, + output_dir: Directory, + entities: dict) -> tuple[list[list[str]], tuple[File]]: + """ + Superficial white matter (SWM) parcellation. + + Superficial white matter (SWM) parcellation was performed using GeoLab, + a geometry-based tractography parcellation framework that segments + hundreds of short-range bundles by combining streamline-geometry features + with atlas-based registration. + + Parameters + ---------- + tractogram_file : File + Path to the tractogram file of one subject. + affine_file : File + Path to the MNI template to T1 affine transform. + invwarp_file : File + Path to the T1 to MNI template warp. + workspace_dir: Directory + Working directory with the workspace of the current processing. + output_dir : Directory + Directory where the reoriented image will be saved. + entities : dict + A dictionary of parsed BIDS entities including modality. + + Returns + ------- + command : list[list[str]] + Parcellation computation command-lines. + outputs : tuple[File] + - mrtrix_warp_file : File - T1 to template MrTrix warp file. + - labels_file : File - Text file containing atlas tractogram labels. + - qc_file : File - TSV file containing the template overlap scores. + """ + opts = brainprep_options.get() + mni_2iso_file = opts.get( + "mni_2iso_file", DEFAULT_OPTIONS["mni_2iso_file"] + ) + atlas_dir = opts.get( + "geolab_atlas_dir", DEFAULT_OPTIONS["geolab_atlas_dir"] + ) + subject, session = entities["sub"], entities["ses"] + basename = f"sub-{subject}_ses-{session}_desc-geolab" + names = [ + "comparisonWithAtlas.tsv", + "labels.dict", + "labels.txt", + "labelsSBR.dict", + "labelsSBR.txt", + "regroupedRecognized.tck", + ] + output_dir_ = output_dir / "labels" + output_dir_.mkdir(parents=True, exist_ok=True) + + mrtrix_warp_file = ( + output_dir / + "anat" / + "transforms" / + "t1_to_template_mrtrix_warp_corrected.mif" + ) + mrtrix_warp_file.parent.mkdir(parents=True, exist_ok=True) + + commands = [ + [ + "warpinit", + str(mni_2iso_file), + str(workspace_dir / "identity_warp[].nii"), + "-force", + ], + *[ + [ + "WarpImageMultiTransform", + "3", + str(workspace_dir / f"identity_warp{idx}.nii"), + str(workspace_dir / f"mrtrix_warp{idx}.nii"), + "-R", str(mni_2iso_file), + "-i", str(invwarp_file), + str(invwarp_file), + ] + for idx in range(3) + ], + [ + "warpcorrect", + str(workspace_dir / "mrtrix_warp[].nii"), + str(mrtrix_warp_file), + "-force", + ], + [ + "tcktransform", + str(tractogram_file), + str(mrtrix_warp_file), + str(workspace_dir / "tractogram_MNI.tck"), + "-force", + ], + [ + "tckresample", + str(workspace_dir / "tractogram_MNI.tck"), + str(workspace_dir / "tractogram_15pts_MNI.tck"), + "-num_points", "15", + "-force", + ], + ] + + if not atlas_dir.is_dir(): + print_warn(f"no atlas directory: {atlas_dir}") + return commands, [mrtrix_warp_file, None, None] + atlas_names = os.listdir(atlas_dir) + + commands.extend([ + *[ + [ + "ProjectAtlasGeoLab", + "-i", str(workspace_dir / "tractogram_15pts_MNI.tck"), + "-o", str(workspace_dir / atlas_name), + "-a", str(atlas_dir / atlas_name / "bundles"), + "-an", str(atlas_dir / atlas_name / "neighbors"), + "-anc", str(atlas_dir / atlas_name / "centroids"), + "-nbPoints", "15", + "-nbThreads", "10", + "-sp", "false", + ] + for atlas_name in atlas_names + ], + *[ + [ + "cp", + str(workspace_dir / atlas_name / name), + str(output_dir_ / f"{basename}_rec-{atlas_name}_{name}"), + ] + for atlas_name in atlas_names + for name in names + ], + ]) + + return commands, [ + mrtrix_warp_file, + [ + output_dir_ / f"{basename}_rec-{atlas_name}_labels.txt" + for atlas_name in atlas_names + ], + [ + ( + output_dir_ / + f"{basename}_rec-{atlas_name}_comparisonWithAtlas.tsv" + ) + for atlas_name in atlas_names + ], + ] diff --git a/brainprep/interfaces/morphologist.py b/brainprep/interfaces/morphologist.py new file mode 100644 index 00000000..3621cba1 --- /dev/null +++ b/brainprep/interfaces/morphologist.py @@ -0,0 +1,356 @@ +########################################################################## +# NSAp - Copyright (C) CEA, 2021 - 2025 +# Distributed under the terms of the CeCILL-B license, as published by +# the CEA-CNRS-INRIA. Refer to the LICENSE file or to +# http://www.cecill.info/licences/Licence_CeCILL-B_V1-en.html +# for details. +########################################################################## + + +""" +Morphologist functions. +""" + +import shutil +from pathlib import Path + +import pandas as pd + +from ..decorators import ( + CoerceparamsHook, + CommandLineWrapperHook, + LogRuntimeHook, + OutputdirHook, + PythonWrapperHook, + SignatureHook, + step, +) +from ..typing import ( + Directory, + File, +) +from ..utils import ( + parse_bids_keys, + print_warn, +) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + bunched=False + ), + SignatureHook(), + ] +) +def morphologist_workflow( + t1_file: File, + output_dir: Directory, + workspace_dir: Directory, + entities: dict) -> tuple[list[File], File]: + """ + Sulci reconstruction and identification using morphologist. + + Parameters + ---------- + t1_file : File + Path to the input T1w image file. + output_dir : Directory + Directory where the prep-processing related outputs will be saved. + workspace_dir: Directory + Working directory with the workspace of the current processing, and + where subject specific data are symlinked. + entities : dict + A dictionary of parsed BIDS entities including modality.* + + Returns + ------- + sulci_graphs_files : list[File] + Left and right hemispheres sulci. + qc_file : File + QC TSV file. + """ + morphologist_cmd( + t1_file, + output_dir, + workspace_dir, + entities, + ) + return morphologist_move( + output_dir, + workspace_dir, + entities, + ) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + bunched=False + ), + CommandLineWrapperHook(), + SignatureHook(), + ] +) +def morphologist_cmd( + t1_file: File, + output_dir: Directory, + workspace_dir: Directory, + entities: dict) -> list[str]: + """ + Morphologist command wrapper. + + Parameters + ---------- + t1_file : File + Path to the input T1w image file. + output_dir : Directory + Directory where the prep-processing related outputs will be saved. + workspace_dir: Directory + Working directory with the workspace of the current processing, and + where subject specific data are symlinked. + entities : dict + A dictionary of parsed BIDS entities including modality. + + Returns + ------- + command : list[str] + Pre-processing command-line. + """ + output_dir = output_dir / f"run-{entities['run']}" + output_dir.mkdir(parents=True, exist_ok=True) + + command = [ + "morphologist-wrapper", + str(t1_file), + str(workspace_dir), + "--subject", entities["sub"], + ] + + return command + + +@step( + hooks=[ + CoerceparamsHook(), + LogRuntimeHook( + bunched=False + ), + PythonWrapperHook(), + SignatureHook(), + ] +) +def morphologist_move( + output_dir: Directory, + workspace_dir: Directory, + entities: dict, + dryrun: bool = False) -> tuple[list[File], File]: + """ + Morpholigist validation. + + Move Morphologist outputs (QC file and run directory) from the workspace + into the final BIDS derivatives structure, and return the expected output + file paths. + + Parameters + ---------- + output_dir : Directory + Directory where the prep-processing related outputs will be saved. + workspace_dir: Directory + Working directory with the workspace of the current processing, and + where subject specific data are symlinked. + entities : dict + A dictionary of parsed BIDS entities including modality. + dryrun : bool + If True, skip actual computation and file writing. Default False. + + Returns + ------- + sulci_graphs_files : list[File] + Left and right hemispheres sulci. + qc_file : File + QC TSV file. + """ + run_output_dir = output_dir / f"run-{entities['run']}" + qc_file = ( + run_output_dir / + "qc.tsv" + ) + subject, session, run = entities["sub"], entities["ses"], entities["run"] + sulci_graphs_files = [( + run_output_dir / + "anat" / + "folds" / + "3.1" / + f'sub-{subject}_ses-{session}_run-{run}_hemi-{hemi}.arg') + for hemi in ["L", "R"] + ] + + if dryrun: + return (sulci_graphs_files, qc_file) + + qc_src_file = _get_single_match( + workspace_dir, + "morphologist-*/qc/qc.tsv", + "'qc.tsv' file" + ) + shutil.move(qc_src_file, qc_file) + + run_src_dir = _get_single_match( + workspace_dir, + "morphologist-*/sub-*/ses-*/run-*", + "'run-*' directory" + ) + replacements = [ + ("ana-0", "anat"), + ("ses-0", f"ses-{session}"), + ("run-0", f"run-{run}"), + (f"sub-\"{subject}\"", f"sub-{subject}"), + ] + for path in run_src_dir.rglob("*"): + rel = str(path.relative_to(run_src_dir)) + for old, new in replacements: + rel = rel.replace(old, new) + target = run_output_dir / rel + if path.is_file(): + if path.suffix == ".minf": + continue + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, target) + + return (sulci_graphs_files, qc_file) + + +def _get_single_match( + workspace_dir: Directory, + pattern: str, + error_label: str) -> Path: + """ + Return a single filesystem match for a glob pattern inside the + workspace derivatives directory. + + Parameters + ---------- + workspace_dir : Directory + Base workspace directory containing a 'derivatives' subdirectory. + pattern : str + Glob pattern relative to 'derivatives' used to locate the file or + directory. + error_label : str + Human-readable label used in error messages. + + Returns + ------- + Path + The unique matched path. + + Raises + ------ + FileNotFoundError + If no match is found. + RuntimeError + If more than one match is found. + """ + derivatives = workspace_dir / "derivatives" + matches = list(derivatives.glob(pattern)) + + if not matches: + raise FileNotFoundError(f"No {error_label} found (pattern: {pattern})") + + if len(matches) > 1: + raise RuntimeError( + f"Multiple {error_label} found ({len(matches)} matches): {matches}" + ) + + return matches[0] + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook( + morphometry=True + ), + LogRuntimeHook( + bunched=False, + parent=True + ), + PythonWrapperHook(), + SignatureHook(), + ] +) +def morphologist_morphometry( + output_dir: Directory, + dryrun: bool = False) -> list[File]: + """ + Extract ROI-based morphometry features and global tissue volumes from + morphologist outputs. + + This function parses morphologist `.csv` ROI statistics. It generates two + TSV files for sulcal and brain volumes morphometries. + + Parameters + ---------- + output_dir : Directory + Working directory containing the outputs. + dryrun : bool + If True, skip actual computation and file writing. Default False. + + Returns + ------- + morphometry_files : list[File] + TSV files containing ROI-based sulcal and brain volumes morphometries. + """ + morphometry_files = [ + output_dir / f"sulcal_morphologist_roi.tsv", + output_dir / f"brain_volumes_morphologist_roi.tsv", + ] + + if dryrun: + return (morphometry_files, ) + + patterns = [ + ("sub-*/ses-*/run-*/anat/folds/3.1/sul-0_auto/sub-*_ses-*_run-*_sul-0_" + "auto_sulcal_morphometry.csv"), + ("sub-*/ses-*/run-*/anat/segmentation/sub-*_ses-*_run-*_sul-0_brain_" + "volumes.csv"), + ] + for pattern, output_file in zip(patterns, morphometry_files, strict=True): + csv_files = list((output_dir.parent / "subjects").glob(pattern)) + if len(csv_files) == 0: + print_warn(f"No data found: {pattern}") + continue + entities = [ + parse_bids_keys(path) + for path in csv_files + ] + data = [] + for info, path in zip(entities, csv_files, strict=True): + df = pd.read_csv(path, sep=";") + if "subject" in df: + df.drop(columns=["subject"], inplace=True) + if len(df) > 1: + df.drop(columns=["sulcus"], inplace=True) + df = df.set_index(["label", "side"]).stack() + df.index = [ + f"{sulcus}_{hemi}_{metric}" + for (sulcus, hemi, metric) in df.index + ] + df = df.to_frame().T + df.insert(0, "participant_id", info["sub"]) + df.insert(1, "session", info["ses"]) + df.insert(2, "run", info["run"]) + data.append(df) + df = pd.concat(data) + df.sort_values(by=["participant_id", "session", "run"], inplace=True) + df.to_csv( + output_file, + index=False, + sep="\t", + ) + + return (morphometry_files, ) diff --git a/brainprep/interfaces/mriqc.py b/brainprep/interfaces/mriqc.py index 3d4322f5..872440f2 100644 --- a/brainprep/interfaces/mriqc.py +++ b/brainprep/interfaces/mriqc.py @@ -18,6 +18,7 @@ CommandLineWrapperHook, LogRuntimeHook, OutputdirHook, + SignatureHook, step, ) from ..typing import ( @@ -34,12 +35,14 @@ bunched=False ), CommandLineWrapperHook(), + SignatureHook(), ] ) def subject_level_qa( image_files: list[File], workspace_dir: Directory, - output_dir: Directory) -> tuple[list[str], tuple[File | list[File]]]: + output_dir: Directory, + ) -> tuple[list[str], tuple[File | list[File]]]: """ Compute subject level Image Quality Metrics (IQMs) generated by MRIQC. @@ -100,11 +103,13 @@ def subject_level_qa( bunched=False ), CommandLineWrapperHook(), + SignatureHook(), ] ) def group_level_qa( modalities: list[str], - output_dir: Directory) -> tuple[list[str], tuple[File | list[File]]]: + output_dir: Directory, + ) -> tuple[list[str], tuple[File | list[File]]]: """ Compute group level Image Quality Metrics (IQMs) generated by MRIQC. diff --git a/brainprep/interfaces/mrtrix3.py b/brainprep/interfaces/mrtrix3.py new file mode 100644 index 00000000..3ea45ab4 --- /dev/null +++ b/brainprep/interfaces/mrtrix3.py @@ -0,0 +1,259 @@ +########################################################################## +# NSAp - Copyright (C) CEA, 2021 - 2026 +# Distributed under the terms of the CeCILL-B license, as published by +# the CEA-CNRS-INRIA. Refer to the LICENSE file or to +# http://www.cecill.info/licences/Licence_CeCILL-B_V1-en.html +# for details. +########################################################################## + +""" +MrTrix3 functions. +""" + +import shutil + +from ..decorators import ( + CoerceparamsHook, + CommandLineWrapperHook, + LogRuntimeHook, + OutputdirHook, + SignatureHook, + step, +) +from ..typing import ( + Directory, + File, +) +from ..utils import ( + bvecbval_from_file, + sidecar_from_file, +) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + bunched=False + ), + CommandLineWrapperHook(), + SignatureHook(), + ] +) +def dwiprep( + t1_file: File, + dwi_files: list[File], + workspace_dir: Directory, + output_dir: Directory, + entities: dict, + ) -> tuple[list[list[str]], tuple[File]]: + """ + Preprocessed BIDS-compliant diffusion weighted image (DWI) using MrTrix3 + `mrtrix3_connectome` pipeline. + + The operations performed by the `preproc` step: + + - **DWI**: Denoising; Gibbs ringing removal; motion, eddy current and + EPI distortion correction and outlier detection & replacement; brain + masking, bias field correction and intensity normalization; rigid-body + registration & transformation to T1-weighted image. + - **T1-weighted image**: bias field correction; brain masking. + + And the the `participant` step: + + - **DWI**: Response function estimation; FOD estimation. + - **T1-weighted image**: Tissue segmentation; gray matter parcellation. + - **Combined**: Whole-brain streamlines tractography; SIFT2; connectome + construction. + + Parameters + ---------- + t1_file : File + Path to the input T1w image file. + dwi_files : list[File] + Path to the input diffusion weighted image files of one subject. + workspace_dir: Directory + Working directory with the workspace of the current processing. + output_dir : Directory + Directory where the preprocessed data will be saved. + entities : dict + A dictionary of parsed BIDS entities including modality. + + Returns + ------- + commands : list[list[str]] + DWI preprocessing command-lines. + outputs : tuple[File] + - dwi_preproc_file : File - The preprocessed DWI image file. + - wm_fod_file : File - The WM fiber orientation distributions file. + - tractogram_file : File - The tractogram file. + - mask_file : File - The brain mask file. + - connectome_file : File - The structural connectome file. + - affine_file : File - Path to the MNI template to T1 affine transform. + - warp_file : File - Path to the MNI template to T1 warp. + - invwarp_file : File - Path to the T1 to MNI template warp. + """ + subject, session = entities["sub"], entities["ses"] + basename = f"sub-{subject}_ses-{session}" + + rawdata_dir = workspace_dir / "rawdata" + anat_dir = rawdata_dir / f"sub-{subject}" / f"ses-{session}" / "anat" + dwi_dir = rawdata_dir / f"sub-{subject}" / f"ses-{session}" / "dwi" + fmap_dir = rawdata_dir / f"sub-{subject}" / f"ses-{session}" / "fmap" + work_dir = workspace_dir / "work" + preproc_scratch_dir = workspace_dir / "scratch" / "preproc" + participant_scratch_dir = workspace_dir / "scratch" / "participant" + source_fmap_dir = output_dir / "fmap" + transforms_dir = output_dir / "anat" / "transforms" + + for path in ( + anat_dir, dwi_dir, work_dir, + preproc_scratch_dir, participant_scratch_dir, + transforms_dir): + path.mkdir(parents=True, exist_ok=True) + + for source_file, target_dir in zip( + [t1_file, *dwi_files], + [anat_dir] + [dwi_dir] * len(dwi_files), + strict=True): + sidecar_source_file = sidecar_from_file(source_file) + bvec_source_file, bval_source_file = bvecbval_from_file(source_file) + if not (target_dir / source_file.name).is_file(): + shutil.copy( + source_file, + target_dir / source_file.name, + ) + shutil.copy( + sidecar_source_file, + target_dir / sidecar_source_file.name, + ) + if bvec_source_file is not None: + shutil.copy( + bvec_source_file, + target_dir / bvec_source_file.name, + ) + if bval_source_file is not None: + shutil.copy( + bval_source_file, + target_dir / bval_source_file.name, + ) + if source_fmap_dir.is_dir() and not fmap_dir.is_symlink(): + fmap_dir.symlink_to(source_fmap_dir) + + preproc_dir = work_dir / "MRtrix3_connectome-preproc" + participant_dir = work_dir / "MRtrix3_connectome-participant" + scratch_dir = ( + participant_dir / f"sub-{subject}" / f"ses-{session}" / "scratch" + ) + + commands = [ + [ + "mrtrix3_connectome", + str(rawdata_dir), + str(work_dir), + "preproc", + "-participant_label", subject, + "-session_label", session, + "-concat_denoise", "before", + # "-eddy_cubicflm", + # "-eddy_mbs", + "-output_verbosity", "4", + "-nthreads", "10", + "-skip-bids-validator", + "-scratch", str(preproc_scratch_dir), + "-nocleanup", + "-debug", + ], + [ + "mrtrix3_connectome", + str(rawdata_dir), + str(work_dir), + "participant", + "-participant_label", subject, + "-session_label", session, + "-parcellation", "craddock200", + "-streamlines", "10000000", + "-output_verbosity", "4", + "-nthreads", "10", + "-skip-bids-validator", + "-scratch", str(participant_scratch_dir), + "-nocleanup", + "-debug", + ], + *[ + [ + "mkdir", + "-p", + str(output_dir / dirname), + ] + for dirname in ("scratch/participant", "scratch/preproc") + ], + *[ + [ + "cp", + "-r", + str(preproc_dir / f"sub-{subject}" / f"ses-{session}" / + dirname), + str(output_dir / ( + dirname if dirname != "scratch" else "scratch/preproc" + )), + ] + for dirname in ("anat", "dwi", "scratch") + ], + *[ + [ + "cp", + "-r", + str(participant_dir / f"sub-{subject}" / f"ses-{session}" / + dirname), + str(output_dir / ( + dirname if dirname != "scratch" else "scratch/participant" + )), + ] + for dirname in ("connectome", "tractogram", "scratch") + ], + *[ + [ + "find", + str(participant_dir / f"sub-{subject}" / f"ses-{session}" / + dirname), + "-maxdepth", "1", + "-exec", + "sh", + "-c", + 'cp -r "$1" "$2"', + "_", + "{}", + str(output_dir / dirname), + ";", + ] + for dirname in ("anat", "dwi") + ], + *[ + [ + "cp", + str(scratch_dir / name), + str(transforms_dir / name), + ] + for name in ( + "template_to_t1_0GenericAffine.mat", + "template_to_t1_1InverseWarp.nii.gz", + "template_to_t1_1Warp.nii.gz", + ) + ], + ] + + return commands, [ + output_dir / "dwi" / f"{basename}_desc-preproc_dwi.nii.gz", + output_dir / "dwi" / f"{basename}_tissue-WM_ODF.nii.gz", + output_dir / "tractogram" / f"{basename}_tractogram.tck", + output_dir / "dwi" / f"{basename}_desc-brain_mask.nii.gz", + ( + output_dir / "connectome" / + f"{basename}_desc-craddock200_connectome.csv" + ), + transforms_dir / "template_to_t1_0GenericAffine.mat", + transforms_dir / "template_to_t1_1InverseWarp.nii.gz", + transforms_dir / "template_to_t1_1Warp.nii.gz", + ] diff --git a/brainprep/interfaces/plotting.py b/brainprep/interfaces/plotting.py index e6509bc7..ea059c58 100644 --- a/brainprep/interfaces/plotting.py +++ b/brainprep/interfaces/plotting.py @@ -13,6 +13,7 @@ import itertools import warnings +import matplotlib.lines as mlines import matplotlib.pyplot as plt import nibabel import numpy as np @@ -32,6 +33,7 @@ LogRuntimeHook, OutputdirHook, PythonWrapperHook, + SignatureHook, step, ) from ..typing import ( @@ -47,9 +49,11 @@ plotting=True ), LogRuntimeHook( - bunched=False + bunched=False, + parent=True ), PythonWrapperHook(), + SignatureHook(), ] ) def plot_network( @@ -107,9 +111,11 @@ def plot_network( plotting=True ), LogRuntimeHook( - bunched=False + bunched=False, + parent=True ), PythonWrapperHook(), + SignatureHook(), ] ) def plot_defacing_mosaic( @@ -140,7 +146,7 @@ def plot_defacing_mosaic( mosaic_file : File Path to the saved mosaic image. """ - basename = "sub-{sub}_ses-{ses}_run-{run}_mod-T1w_deface".format( + basename = "sub-{sub}_ses-{ses}_run-{run}_mod-{mod}_deface".format( **entities) mosaic_file = output_dir / f"{basename}mosaic.png" @@ -176,9 +182,11 @@ def plot_defacing_mosaic( plotting=True ), LogRuntimeHook( - bunched=False + bunched=False, + parent=True ), PythonWrapperHook(), + SignatureHook(), ] ) def plot_histogram( @@ -186,6 +194,7 @@ def plot_histogram( col_name: str, output_dir: Directory, bar_coords: list[float] | None = None, + suffix: str | None = None, dryrun: bool = False) -> tuple[File]: """ Generates a histogram image with optional vertical bars. @@ -199,7 +208,11 @@ def plot_histogram( output_dir : Directory Directory where the image with the histogram will be saved. bar_coords: list[float] | None - Coordianates of vertical lines to be displayed in red. Default None. + Coordianates of vertical lines to be displayed in red. + Default None. + suffix : str | None + Suffix added to the generated PNG file. + Default None. dryrun : bool If True, skip actual computation and file writing. Default False. @@ -208,7 +221,7 @@ def plot_histogram( histogram_file : File Generated image with the histogram. """ - histogram_file = output_dir / f"histogram_{col_name}.png" + histogram_file = output_dir / f"histogram_{col_name}{suffix or ''}.png" if dryrun: return (histogram_file, ) @@ -235,7 +248,6 @@ def plot_histogram( ax.axvline(x=x_coord, color="red") ax.spines["right"].set_visible(False) ax.spines["top"].set_visible(False) - ax.legend() plt.savefig(histogram_file) @@ -249,9 +261,11 @@ def plot_histogram( plotting=True ), LogRuntimeHook( - bunched=False + bunched=False, + parent=True ), PythonWrapperHook(), + SignatureHook(), ] ) def plot_brainparc( @@ -343,14 +357,17 @@ def plot_brainparc( plotting=True ), LogRuntimeHook( - bunched=False + bunched=False, + parent=True ), PythonWrapperHook(), + SignatureHook(), ] ) def plot_pca( pca_file: File, output_dir: Directory, + suffix: str | None = None, dryrun: bool = False) -> tuple[File]: """ Plot the two first PCA components. @@ -363,6 +380,9 @@ def plot_pca( and ``run``. output_dir : Directory Directory where the result image will be saved. + suffix : str | None + Suffix added to the generated PNG file. + Default None. dryrun : bool If True, skip actual computation and file writing. Default False. @@ -371,7 +391,7 @@ def plot_pca( pca_image_file : File Generated image with the two first PCA components. """ - pca_image_file = output_dir / f"pca.png" + pca_image_file = output_dir / f"pca{suffix or ''}.png" if dryrun: return (pca_image_file, ) @@ -380,15 +400,35 @@ def plot_pca( fig, ax = plt.subplots(figsize=(20, 10)) ax.scatter(df.pc1, df.pc2) - for idx in range(len(df)): - ax.annotate( - f"{df.participant_id[idx]}-{df.session[idx]}-{df.run[idx]}", - xy=(df.pc1[idx], df.pc2[idx]), + df.apply( + lambda row: ax.annotate( + f"{row.participant_id}-{row.session}-{row.run}", + xy=(row.pc1, row.pc2), xytext=(4, 4), - textcoords="offset pixels" - ) - plt.xlabel(f"PC1 (var={df.explained_variance_ratio_pc1[0]:.2f})") - plt.ylabel(f"PC2 (var={df.explained_variance_ratio_pc2[1]:.2f})") + textcoords="offset pixels", + fontsize=9, + ), + axis=1, + ) + annotation_desc = mlines.Line2D( + [], + [], + color="none", + label="Participant - Session - Run", + ) + ax.legend( + handles=[annotation_desc], + loc="upper right", + frameon=True, + facecolor="#f9f9f9", + edgecolor="gray", + ) + plt.xlabel( + fr"$\mathbf{{PC1}}$ (var={df.explained_variance_ratio_pc1[0]:.2f})" + ) + plt.ylabel( + fr"$\mathbf{{PC2}}$ (var={df.explained_variance_ratio_pc2[1]:.2f})" + ) plt.axis("equal") ax.spines["right"].set_visible(False) ax.spines["top"].set_visible(False) diff --git a/brainprep/interfaces/qualcheck.py b/brainprep/interfaces/qualcheck.py index 0e2fcb0a..75e0780f 100644 --- a/brainprep/interfaces/qualcheck.py +++ b/brainprep/interfaces/qualcheck.py @@ -26,6 +26,7 @@ LogRuntimeHook, OutputdirHook, PythonWrapperHook, + SignatureHook, step, ) from ..typing import ( @@ -46,16 +47,19 @@ quality_check=True ), LogRuntimeHook( - bunched=False + bunched=False, + parent=True ), PythonWrapperHook(), + SignatureHook(), ] ) def network_entropy( network_files_regex: str, output_dir: Directory, entropy_threshold: float = 12, - dryrun: bool = False) -> tuple[File]: + dryrun: bool = False, + ) -> tuple[File]: """ Comput enetwork entropy - "structure vs randomness" metric. @@ -68,15 +72,17 @@ def network_entropy( ---------- network_files_regex : str A regular expression matching TSV files generated by - ``func_vol_connectivity``. These files must contain regions + ``fmri_connectivity``. These files must contain regions as index and columns with connectivity values. output_dir : Directory Directory where a TSV file containing the mean correlation values is created. entropy_threshold : float - Quality control threshold applied on the entropy score. Default 12. + Quality control threshold applied on the entropy score. + Default 12. dryrun : bool - If True, skip actual computation and file writing. Default False. + If True, skip actual computation and file writing. + Default False. Returns ------- @@ -146,16 +152,20 @@ def network_entropy( quality_check=True ), LogRuntimeHook( - bunched=False + bunched=False, + parent=True ), PythonWrapperHook(), + SignatureHook(), ] ) -def mask_overlap( +def maskoverlap( maskdiff_files_regex: str, output_dir: Directory, overlap_threshold: float = 0.05, - dryrun: bool = False) -> tuple[File]: + suffix: str | None = None, + dryrun: bool = False, + ) -> tuple[File]: """ Compute overlap ratios between mask pairs from `maskdiff` summary files. @@ -175,9 +185,14 @@ def mask_overlap( Directory where a TSV file containing the mean correlation values is created. overlap_threshold : float - Quality control threshold applied on the overalp score. Default 0.05. + Quality control threshold applied on the overalp score. + Default 0.05. + suffix : str | None + Suffix added to the generated TSV file. + Default None. dryrun : bool - If True, skip actual computation and file writing. Default False. + If True, skip actual computation and file writing. + Default False. Returns ------- @@ -194,7 +209,7 @@ def mask_overlap( indicating whether the overlap score do not exceeds the threshold: ``qc = 1`` if ``overlap < overlap_threshold``, otherwise ``qc = 0``. """ - overlap_file = output_dir / "mask_overlap.tsv" + overlap_file = output_dir / f"mask_overlap{suffix or ''}.tsv" if dryrun: return (overlap_file, ) @@ -252,35 +267,53 @@ def mask_overlap( quality_check=True ), LogRuntimeHook( - bunched=False + bunched=False, + parent=True ), PythonWrapperHook(), + SignatureHook(), ] ) -def mean_correlation( - image_files_regex: str, - atlas_file: File, +def meancorr( + data_files_regex: str, + atlas_file: File | None, output_dir: Directory, - correlation_threshold: float = 0.5, - dryrun: bool = False) -> tuple[File]: + correlation_threshold: float | None = 0.5, + suffix: str | None = None, + dryrun: bool = False, + ) -> tuple[File]: """ Compute the mean Pearson correlation between a reference image and a list - of other images. + of input images. + + It can use pre-computed correlation data for each subject or compute the + correlation internally. + If the correlaton is computed internally, individual pre-computed + correlation data are saved in the ``subjects`` directory. + Additionally, it performs quality control based on a specified correlation + threshold. Parameters ---------- - image_files_regex : str + data_files_regex : str A REGEX to image files, each representing an image of the same shape - and geometry as `atlas_file`. - atlas_file : File - An file representing the reference image. + and geometry as `atlas_file` or pre-computed TSV correlation data. + atlas_file : File | None + A file representing the reference image. If None, expect pre-computed + correlation data. output_dir : Directory Directory where a TSV file containing the mean correlation values is created. - correlation_threshold : float - Quality control threshold on the correlation score. Default 0.5. + correlation_threshold : float | None + Quality control threshold on the correlation score. + If None do not add the ``qc`` column. + Default 0.5. + suffix : str | None + Suffix added to the generated TSV file. + Default None. dryrun : bool - If True, skip actual computation and file writing. Default False. + If True, skip actual computation and file writing. + Default False. Returns ------- @@ -295,6 +328,7 @@ def mean_correlation( ------ ValueError If the atlas and an image have incompatible shape or geometry. + If invalid pre-computed correlation data are provided. Notes ----- @@ -303,17 +337,18 @@ def mean_correlation( ``qc = 1`` if ``mean_correlation > correlation_threshold``, otherwise ``qc = 0``. """ - correlations_file = output_dir / "mean_correlations.tsv" + correlations_file = output_dir / f"mean_correlations{suffix or ''}.tsv" if dryrun: return (correlations_file, ) image_files = coerce_to_path( - glob.glob(str(image_files_regex)), + glob.glob(str(data_files_regex)), expected_type=list[File], ) - atlas_im = nibabel.load(atlas_file) - atlas_arr = atlas_im.get_fdata() + if atlas_file is not None: + atlas_im = nibabel.load(atlas_file) + atlas_arr = atlas_im.get_fdata() scores = pd.DataFrame( columns=( @@ -325,30 +360,55 @@ def mean_correlation( ) for path in image_files: entities = parse_bids_keys(path) - im = nibabel.load(path) - arr = atlas_im.get_fdata() - if atlas_arr.shape != arr.shape: - raise ValueError( - f"Atlas and image have incompatible shape: {path}" + if atlas_file is not None: + im = nibabel.load(path) + arr = atlas_im.get_fdata() + if atlas_arr.shape != arr.shape: + raise ValueError( + f"Atlas and image have incompatible shape: {path}" + ) + if not np.allclose(atlas_im.affine, im.affine): + raise ValueError( + f"Atlas and image have incompatible orientation: {path}" + ) + corr, _ = pearsonr( + atlas_arr.flatten(), + arr.flatten(), ) - if not np.allclose(atlas_im.affine, im.affine): - raise ValueError( - f"Atlas and image have incompatible orientation: {path}" - ) - corr, _ = pearsonr( - atlas_arr.flatten(), - arr.flatten(), - ) + else: + df_ = pd.read_csv(path, sep="\t") + if len(df_) != 1: + raise ValueError( + f"Invalid pre-computed correlation data: {path}" + ) + corr = df_.iloc[0]["mean_correlation"] scores.loc[len(scores)] = [ entities["sub"], entities["ses"], entities["run"], - corr, + float(corr), ] + if atlas_file is not None: + individual_score = scores.iloc[[-1]] + basename = "sub-{sub}_ses-{ses}_run-{run}_mod-{mod}".format( + **entities + ) + individual_score_file = ( + path.parent / + "quality_check" / + f"{basename}_corr.tsv" + ) + individual_score_file.parent.mkdir(parents=True, exist_ok=True) + individual_score.to_csv( + individual_score_file, + sep="\t", + index=False, + ) - scores["qc"] = ( - scores["mean_correlation"] > correlation_threshold - ).astype(int) + if correlation_threshold is not None: + scores["qc"] = ( + scores["mean_correlation"] > correlation_threshold + ).astype(int) scores = scores.sort_values(by=["participant_id", "session", "run"]) scores.to_csv( correlations_file, @@ -369,13 +429,129 @@ def mean_correlation( bunched=False ), PythonWrapperHook(), + SignatureHook(), + ] +) +def maskdiff( + mask1_file: File, + mask2_file: File, + output_dir: Directory, + entities: dict, + inv_mask1: bool = False, + inv_mask2: bool = False, + dryrun: bool = False, + ) -> tuple[File]: + """ + Compute summary statistics comparing two binary masks. + + This function loads two binary mask images, verifies that they share + the same spatial dimensions and affine transformation, computes their + voxel-wise intersection, and writes a summary table containing voxel + counts and physical volumes (in mm³) for each mask and their intersection. + + Parameters + ---------- + mask1_file : File + Path to the first binary mask image. + mask2_file : File + Path to the second binary mask image. + output_dir : Directory + Directory where the defacing mask will be saved. + entities : dict + A dictionary of parsed BIDS entities including modality. + inv_mask1 : bool + If True, the first mask is inverted before comparison. This is + useful when the mask represents an exclusion region rather than an + inclusion region. + Default False. + inv_mask2 : bool + If True, the second mask is inverted before comparison. This is + useful when the mask represents an exclusion region rather than an + inclusion region. + Default False. + dryrun : bool + If True, skip actual computation and file writing. + Default False. + + Returns + ------- + summary_file : File + Path to the generated summary TSV file. + + Raises + ------ + ValueError + If both masks have not identical shapes and affines. + """ + basename = "sub-{sub}_ses-{ses}_run-{run}_mod-{mod}_maskdiff".format( + **entities) + summary_file = output_dir / f"{basename}.tsv" + + if not dryrun: + + mask1_im = nibabel.load(mask1_file) + mask2_im = nibabel.load(mask2_file) + mask1 = mask1_im.get_fdata().astype(bool) + mask2 = mask2_im.get_fdata().astype(bool) + + if inv_mask1: + mask1 = ~mask1 + if inv_mask2: + mask1 = ~mask2 + + if mask1.shape != mask2.shape: + raise ValueError( + f"Mask shapes differ: {mask1.shape} vs {mask2.shape}. " + "Resampling is required." + ) + if not np.allclose(mask1_im.affine, mask2_im.affine): + raise ValueError( + "Mask affines differ. Resampling is required before " + "intersection." + ) + + intersection = np.logical_and(mask1, mask2) + voxel_volume = np.abs(np.linalg.det(mask1_im.affine[:3, :3])) + + summary_df = pd.DataFrame({ + "mask": ["mask1", "mask2", "intersection"], + "voxels": [ + mask1.sum(), + mask2.sum(), + intersection.sum(), + ], + "volume_mm3": [ + mask1.sum() * voxel_volume, + mask2.sum() * voxel_volume, + intersection.sum() * voxel_volume, + ] + }) + summary_df.to_csv(summary_file, sep="\t", index=False) + + return (summary_file, ) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook( + quality_check=True + ), + LogRuntimeHook( + bunched=False, + parent=True + ), + PythonWrapperHook(), + SignatureHook(), ] ) -def incremental_pca( +def pca( image_files_regex: str, output_dir: Directory, batch_size: int = 10, - dryrun: bool = False) -> tuple[File]: + suffix: str | None = None, + dryrun: bool = False, + ) -> tuple[File]: """ Perform an Incremental PCA with 2 components on a collection of images matched by a regex pattern, processing them in batches. @@ -400,9 +576,13 @@ def incremental_pca( all the graph of all batch. batch_size : int Number of images to use in each batch. If None, a single batch is used. - Default is 10. + Default 10. + suffix : str | None + Suffix added to the generated TSV file. + Default None. dryrun : bool - If True, skip actual computation and file writing. Default False. + If True, skip actual computation and file writing. + Default False. Returns ------- @@ -416,7 +596,7 @@ def incremental_pca( If the dataset contains fewer than 2 images, which prevents PCA computation. """ - pca_file = output_dir / "pca.tsv" + pca_file = output_dir / f"pca{suffix or ''}.tsv" if dryrun: return (pca_file, ) @@ -483,15 +663,18 @@ def incremental_pca( quality_check=True ), LogRuntimeHook( - bunched=False + bunched=False, + parent=True ), PythonWrapperHook(), + SignatureHook(), ] ) -def euler_numbers( +def eulernums( output_dir: Directory, euler_threshold: int = -217, - dryrun: bool = False) -> tuple[File]: + dryrun: bool = False, + ) -> tuple[File]: """ FreeSurfer recon-all quality control. @@ -503,9 +686,11 @@ def euler_numbers( output_dir : Directory FreeSurfer working directory containing all the subjects. euler_threshold : int - Quality control threshold on the Euler number. Default 217. + Quality control threshold on the Euler number. + Default 217. dryrun : bool - If True, skip actual computation and file writing. Default False. + If True, skip actual computation and file writing. + Default False. Returns ------- @@ -594,16 +779,19 @@ def euler_numbers( quality_check=True ), LogRuntimeHook( - bunched=False + bunched=False, + parent=True ), PythonWrapperHook(), + SignatureHook(), ] ) def vbm_metrics( output_dir: Directory, ncr_threshold: float = 4.5, iqr_threshold: float = 4.5, - dryrun: bool = False) -> tuple[File]: + dryrun: bool = False, + ) -> tuple[File]: """ CAT12 VBM quality control. @@ -627,11 +815,14 @@ def vbm_metrics( output_dir : Directory Working directory containing the outputs. ncr_threshold : float - Quality control threshold on the NCR scores. Default 4.5. + Quality control threshold on the NCR scores. + Default 4.5. iqr_threshold : float - Quality control threshold on the IQR scores. Default 4.5. + Quality control threshold on the IQR scores. + Default 4.5. dryrun : bool - If True, skip actual computation and file writing. Default False. + If True, skip actual computation and file writing. + Default False. Returns ------- @@ -708,16 +899,19 @@ def vbm_metrics( quality_check=True ), LogRuntimeHook( - bunched=False + bunched=False, + parent=True ), PythonWrapperHook(), + SignatureHook(), ] ) def fmriprep_metrics( output_dir: Directory, fd_mean_threshold: float = 0.2, dvars_std_threshold: float = 1.5, - dryrun: bool = False) -> tuple[File]: + dryrun: bool = False, + ) -> tuple[File]: """ FMRIprep quality control. @@ -739,7 +933,8 @@ def fmriprep_metrics( Quality control threshold on the Standardized DVARS. Default 1.5. dryrun : bool - If True, skip actual computation and file writing. Default False. + If True, skip actual computation and file writing. + Default False. Returns ------- @@ -822,15 +1017,18 @@ def safe_mean(df, col): quality_check=True ), LogRuntimeHook( - bunched=False + bunched=False, + parent=True ), PythonWrapperHook(), + SignatureHook(), ] ) def mriqc_metrics( iqm_files: list[File], output_dir: Directory, - dryrun: bool = False) -> list[File]: + dryrun: bool = False, + ) -> list[File]: """ Filter MRIQC group-level metrics according to modality-specific defaults. @@ -847,7 +1045,8 @@ def mriqc_metrics( output_dir : Directory Directory where the filtered metrics will be written. dryrun : bool - If True, skip actual computation and file writing. Default False. + If True, skip actual computation and file writing. + Default False. Returns ------- @@ -886,7 +1085,8 @@ def mriqc_metrics( ] } for input_file, output_file in zip( - iqm_files, filter_iqm_files, strict=True): + iqm_files, filter_iqm_files, strict=True, + ): if not input_file.stem.startswith("group_"): raise ValueError( @@ -907,3 +1107,81 @@ def mriqc_metrics( df.to_csv(output_file, sep="\t", index=False) return (filter_iqm_files, ) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook( + quality_check=True + ), + LogRuntimeHook( + bunched=False, + parent=True + ), + PythonWrapperHook(), + SignatureHook(), + ] +) +def sulcirec_metrics( + output_dir: Directory, + dryrun: bool = False, + ) -> tuple[File]: + """ + Morphologist quality control. + + Parameters + ---------- + output_dir : Directory + Working directory containing the outputs. + dryrun : bool + If True, skip actual computation and file writing. + Default False. + + Returns + ------- + group_stats_file : File + A TSV file containing a binary ``qc`` column indicating the + morphologist quality control result. + + References + ---------- + + .. footbibliography:: + """ + group_stats_file = output_dir / "group_stats.tsv" + + if dryrun: + return (group_stats_file, ) + + pattern = "sub-*/ses-*/run-*/qc.tsv" + status_files = list( + (output_dir.parent / "subjects").glob(pattern) + ) + if len(status_files) == 0: + print_warn(f"No data found: {pattern}") + entities = [ + parse_bids_keys(path, full_path=True) + for path in status_files + ] + + stats = [] + for info, path in zip(entities, status_files, strict=True): + df_ = pd.read_csv(path, sep="\t") + df = pd.DataFrame({ + "participant_id": [info["sub"]], + "session": [info["ses"]], + "run": [info["run"]], + "qc": [int(all(df_["morpho_qc"].values == "OK"))], + }) + stats.append(df) + df = pd.concat(stats) + df.sort_values(by=["participant_id", "session", "run"], inplace=True) + + df.to_csv( + group_stats_file, + index=False, + sep="\t", + ) + + return (group_stats_file, ) diff --git a/brainprep/interfaces/reporting.py b/brainprep/interfaces/reporting.py new file mode 100644 index 00000000..306d2560 --- /dev/null +++ b/brainprep/interfaces/reporting.py @@ -0,0 +1,341 @@ +########################################################################## +# NSAp - Copyright (C) CEA, 2021 - 2026 +# Distributed under the terms of the CeCILL-B license, as published by +# the CEA-CNRS-INRIA. Refer to the LICENSE file or to +# http://www.cecill.info/licences/Licence_CeCILL-B_V1-en.html +# for details. +########################################################################## + +""" +Reporting functions. +""" + +import json + +import pandas as pd +from sklearn.preprocessing import MinMaxScaler + +from ..decorators import ( + CoerceparamsHook, + LogRuntimeHook, + PythonWrapperHook, + SignatureHook, + step, +) +from ..typing import ( + Directory, + File, +) + + +@step( + hooks=[ + CoerceparamsHook(), + LogRuntimeHook( + bunched=False + ), + PythonWrapperHook(), + SignatureHook(), + ] +) +def parse_defacing( + data_dir: Directory, + output_dir: Directory, + dryrun: bool = False, + ) -> File | None: + """ + Parse defacing workflow QC data and generate a JSON report. + + This function processes defacing quality control data, including overlap + and correlation metrics, and generates a JSON report with scatter plots + and histograms. + + Parameters + ---------- + data_dir : Directory + BIDS root directory containing the input data. + output_dir : Directory + Directory where the output JSON report will be saved. + dryrun : bool + If True, skip actual computation and file writing. + Default False. + + Returns + ------- + config_file : File | None + Path to the generated JSON file if the workflow has been run, + otherwise None. + """ + workflow_dir = ( + data_dir / + "derivatives" / + "defacing" + ) + config_file = ( + output_dir / + "defacing.json" + ) + + if not workflow_dir.is_dir(): + return None + if dryrun: + return (config_file, ) + + scatter_data = {} + scaler = MinMaxScaler() + for mod in ("T1w", "T2w", "FLAIR"): + overlap_file = ( + workflow_dir / + "quality_check" / + f"mask_overlap_{mod}.tsv" + ) + correlation_file = ( + workflow_dir / + "quality_check" / + f"mean_correlations_{mod}.tsv" + ) + if not overlap_file.is_file(): + continue + df1_ = pd.read_csv( + overlap_file, + sep="\t", + dtype=str, + )[["participant_id", "session", "run", "overlap"]] + df2_ = pd.read_csv( + correlation_file, + sep="\t", + dtype=str, + )[["participant_id", "session", "run", "mean_correlation"]] + df_ = pd.merge( + df1_, + df2_, + on=["participant_id", "session", "run"], + how="inner", + ) + df_["img"] = [ + ( + workflow_dir / + "subjects" / + f"sub-{row.participant_id}" / + f"ses-{row.session}" / + "figures" / + f"sub-{row.participant_id}_ses-{row.session}_run-{row.run}_" + f"mod-{mod}_defacemosaic.png" + ).relative_to(data_dir / "derivatives") + for _, row in df_.iterrows() + ] + df_.columns = ["sub", "ses", "run", "x", "y", "img"] + df_ = df_.astype({"x": float, "y": float, "img": str}) + df_[["x", "y"]] = scaler.fit_transform(df_[["x", "y"]]) + scatter_data[f"Scatter {mod}"] = { + "record": df_.to_dict(orient="records"), + "x_label": "Overlap", + "y_label": "Correlation", + "with_img": True, + } + + data_ = {} + for metric, name in ( + ("overlap", "Overlap"), + ("mean_correlation", "Correlation"), + ): + for mod in ("T1w", "T2w", "FLAIR"): + histogram_file = ( + workflow_dir / + "figures" / + f"histogram_{metric}_{mod}.png" + ) + if not histogram_file.is_file(): + continue + data_.setdefault("record", []).append(str(histogram_file)) + data_.setdefault("labels", []).append(f"{name} ({mod})") + carousel_data = { + "Histogram": data_, + } + + data = { + "name": "Defacing", + "carousels": carousel_data, + "scatters": scatter_data, + } + with config_file.open("w") as of: + json.dump(data, of, indent=4) + + return (config_file, ) + + +@step( + hooks=[ + CoerceparamsHook(), + LogRuntimeHook( + bunched=False + ), + PythonWrapperHook(), + SignatureHook(), + ] +) +def parse_quasiraw( + data_dir: Directory, + output_dir: Directory, + dryrun: bool = False, + ) -> File | None: + """ + Parse quasiraw workflow QC data and generate a JSON report. + + This function processes quasiraw quality control data, including PCA + and correlation metrics, and generates a JSON report with scatter plots + and histograms. + + Parameters + ---------- + data_dir : Directory + BIDS root directory containing the input data. + output_dir : Directory + Directory where the output JSON report will be saved. + dryrun : bool + If True, skip actual computation and file writing. + Default False. + + Returns + ------- + config_file : File | None + Path to the generated JSON file if the workflow has been run, + otherwise None. + """ + workflow_dir = ( + data_dir / + "derivatives" / + "quasiraw" + ) + config_file = ( + output_dir / + "quasiraw.json" + ) + + if not workflow_dir.is_dir(): + return None + if dryrun: + return (config_file, ) + + scaler = MinMaxScaler() + scatter_data = {} + for mod in ("T1w", "T2w", "FLAIR"): + df_ = pd.read_csv( + ( + workflow_dir / + "quality_check" / + f"pca_{mod}.tsv" + ), + sep="\t", + dtype=str, + )[["participant_id", "session", "run", "pc1", "pc2"]] + df_["img"] = None + df_.columns = ["sub", "ses", "run", "x", "y", "img"] + df_ = df_.astype({"x": float, "y": float, "img": str}) + df_[["x", "y"]] = scaler.fit_transform(df_[["x", "y"]]) + scatter_data[f"Scatter {mod}"] = { + "record": df_.to_dict(orient="records"), + "x_label": "PC1", + "y_label": "PC2", + "with_img": False, + } + + data_ = {} + for mod in ("T1w", "T2w", "FLAIR"): + histogram_file = ( + workflow_dir / + "figures" / + f"histogram_mean_correlation_{mod}.png" + ) + if not histogram_file.is_file(): + continue + data_.setdefault("record", []).append(str(histogram_file)) + data_.setdefault("labels", []).append(f"Correlation ({mod})") + carousel_data = { + "Histogram": data_, + } + + data = { + "name": "QuasiRaw", + "carousels": carousel_data, + "scatters": scatter_data, + } + with config_file.open("w") as of: + json.dump(data, of, indent=4) + + return (config_file, ) + + +@step( + hooks=[ + CoerceparamsHook(), + LogRuntimeHook( + bunched=False + ), + PythonWrapperHook(), + SignatureHook(), + ] +) +def parse_qa( + data_dir: Directory, + output_dir: Directory, + dryrun: bool = False, + ) -> File | None: + """ + Parse quality assurance workflow QC data and generate a JSON report. + + Parameters + ---------- + data_dir : Directory + BIDS root directory containing the input data. + output_dir : Directory + Directory where the output JSON report will be saved. + dryrun : bool + If True, skip actual computation and file writing. + Default False. + + Returns + ------- + config_file : File | None + Path to the generated JSON file if the workflow has been run, + otherwise None. + """ + workflow_dir = ( + data_dir / + "derivatives" / + "quality_assurance" + ) + config_file = ( + output_dir / + "quality_assurance.json" + ) + + if not workflow_dir.is_dir(): + return None + if dryrun: + return (config_file, ) + + list_files = [] + for mod in ("bold", "dwi", "T1w", "T2w", "FLAIR"): + file_ = ( + workflow_dir / + f"group_{mod}.html" + ) + if not file_.is_file(): + continue + file_ = file_.relative_to(data_dir / "derivatives") + if mod in ("bold", "dwi"): + mod = mod.upper() + list_files.append( + f"
  • {mod} QC: here
  • " + ) + html_summary = f"" + + data = { + "name": "Quality Assurance", + "summary": html_summary, + } + with config_file.open("w") as of: + json.dump(data, of, indent=4) + + return (config_file, ) diff --git a/brainprep/interfaces/synb0disco.py b/brainprep/interfaces/synb0disco.py new file mode 100644 index 00000000..02891839 --- /dev/null +++ b/brainprep/interfaces/synb0disco.py @@ -0,0 +1,174 @@ +########################################################################## +# NSAp - Copyright (C) CEA, 2021 - 2026 +# Distributed under the terms of the CeCILL-B license, as published by +# the CEA-CNRS-INRIA. Refer to the LICENSE file or to +# http://www.cecill.info/licences/Licence_CeCILL-B_V1-en.html +# for details. +########################################################################## + +""" +Synb0-DISCO functions. +""" + +from ..decorators import ( + CoerceparamsHook, + CommandLineWrapperHook, + LogRuntimeHook, + OutputdirHook, + SignatureHook, + step, +) +from ..typing import ( + Directory, + File, +) +from ..utils import ( + bvecbval_from_file, + sidecar_from_file, +) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + bunched=False + ), + CommandLineWrapperHook(), + SignatureHook(), + ] +) +def synthb0( + t1_file: File, + dwi_file: File, + workspace_dir: Directory, + output_dir: Directory, + entities: dict, + mask_file: File | None = None + ) -> tuple[list[list[str]], tuple[File]]: + """ + Apply Synb0-DISCO. + + The Synb0-DISCO tool aims to enable susceptibility distortion correction + with historical and/or limited datasets that do not include specific + sequences for distortion correction (i.e. reverse phase-encoded scans). In + short, the tool synthesizes an "undistorted" b=0 image that matches the + geometry of structural T1w images and also matches the contrast from + diffusion images. This synthesized 'undistorted' image can then be used + in standard pipelines (i.e. TOPUP). + + Parameters + ---------- + t1_file : File + Path to the input T1w image file. + dwi_file : File + Path to the input diffusion weighted image file of one subject. + workspace_dir: Directory + Working directory with the workspace of the current processing. + output_dir : Directory + Directory where the generated images will be saved. + entities : dict + A dictionary of parsed BIDS entities including modality. + mask_file: File | None + Binary brain mask image file. If this parameter is not set, the mask + is computed using bet. + Default None. + + Returns + ------- + commands : list[list[str]] + Synb0-DISCO preprocessing command-lines. + outputs : tuple[File] + - b0_file : File - synthesized b=0 image as input to topup. + - sidecar_file : File - associated JSON sidecar with reverse phase + encoding and an effective echo spacing of 0 (infinite bandwidth). + """ + basename = ( + f"sub-{entities['sub']}_" + f"ses-{entities['ses']}_" + f"run{entities['run']}" + ) + + fmap_dir = output_dir / "fmap" + work_dir = workspace_dir / "work" + for dir_ in (fmap_dir, work_dir, workspace_dir): + dir_.mkdir(parents=True, exist_ok=True) + + sidecar_dwi_file = sidecar_from_file(dwi_file) + _, bval_file = bvecbval_from_file(dwi_file) + + b0_file = fmap_dir / f"{basename}_dir-REVERSE_epi.nii.gz" + sidecar_file = fmap_dir / f"{basename}_dir-REVERSE_epi.json" + + commands = [ + [ + "cp", + str(t1_file), + str(workspace_dir / "T1.nii.gz"), + ] if mask_file is None else + [ + "fslmaths", + str(t1_file), + "-mas", str(mask_file), + str(workspace_dir / "T1.nii.gz"), + ], + [ + "fslroi", + str(dwi_file), + str(workspace_dir / "b0.nii"), + ( + "$(awk " + f"'{{for(i=1;i<=NF;i++) if($i<50) {{print i-1; exit}}}}' " + f"{bval_file}" + ")" + ), + "1", + ], + [ + "synb0", + str(workspace_dir / "b0.nii"), + str(workspace_dir / "T1.nii.gz"), + str(work_dir), + "mni_icbm152_t1_tal_nlin_asym_09c.nii.gz", + ] if mask_file is None else + [ + "synb0", + str(workspace_dir / "b0.nii"), + str(workspace_dir / "T1.nii"), + str(work_dir), + "mni_icbm152_t1_tal_nlin_asym_09c_mask.nii.gz", + ], + [ + "cp", + str(work_dir / "b0_u.nii.gz"), + str(b0_file), + ], + [ + "cp", + str(sidecar_dwi_file), + str(sidecar_file), + ], + [ + "sed", + "-i", + "-E", + 's/("TotalReadoutTime":[[:space:]]*)[0-9.]+/\\10/g', + str(sidecar_file), + ], + [ + "sed", + "-i", + "-E", + ( + 's/("PhaseEncodingDirection":[[:space:]]*"[^"]+)-"/\\1"/g; ' + 't; s/("PhaseEncodingDirection":[[:space:]]*"[^"-]+")/\\1-"/g' + ), + str(sidecar_file), + ], + ] + + return commands, [ + b0_file, + sidecar_file, + ] diff --git a/brainprep/interfaces/tractseg.py b/brainprep/interfaces/tractseg.py new file mode 100644 index 00000000..33d1bdfc --- /dev/null +++ b/brainprep/interfaces/tractseg.py @@ -0,0 +1,183 @@ +########################################################################## +# NSAp - Copyright (C) CEA, 2021 - 2026 +# Distributed under the terms of the CeCILL-B license, as published by +# the CEA-CNRS-INRIA. Refer to the LICENSE file or to +# http://www.cecill.info/licences/Licence_CeCILL-B_V1-en.html +# for details. +########################################################################## + +""" +TractSeg functions. +""" + +from ..decorators import ( + CoerceparamsHook, + CommandLineWrapperHook, + LogRuntimeHook, + OutputdirHook, + SignatureHook, + step, +) +from ..typing import ( + Directory, + File, +) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + bunched=False + ), + CommandLineWrapperHook(), + SignatureHook(), + ] +) +def tractseg_parcellation( + fod_file: File, + mrtrix_warp_file: File, + scalar_map_files: list[File], + scalar_map_names: list[str], + workspace_dir: Directory, + output_dir: Directory, + entities: dict) -> tuple[list[list[str]], tuple[File]]: + """ + White matter (WM) parcellation. + + White-matter bundle segmentation was performed using TractSeg, a + deep-learning-based framework that directly predicts tract-specific + segmentations, orientations, and tractograms from diffusion-derived + fiber-orientation peaks. This approach enables fast and anatomically + consistent delineation of major white-matter pathways without requiring + whole-brain tractography or atlas registration. + + Parameters + ---------- + fod_file : File + Path to the fiber orientation distributions (FOD) file of one subject. + mrtrix_warp_file : File + T1 to MNI MrTrix warp file. + scalar_map_files : list[File] + Scalar maps (i.e., FA, MD, ...) used to derive tractometry data. + scalar_map_names : list[str] + Names associated to scalar maps. + workspace_dir: Directory + Working directory with the workspace of the current processing. + output_dir : Directory + Directory where the reoriented image will be saved. + entities : dict + A dictionary of parsed BIDS entities including modality. + + Returns + ------- + command : list[list[str]] + Parcellation computation command-lines. + outputs : tuple[File] + - tractometry_files : list[File] - Generated tractometry data + for each input scalar map. + """ + subject, session = entities["sub"], entities["ses"] + basename = f"sub-{subject}_ses-{session}_desc-tractseg" + + output_dir1_ = output_dir / "tractometry" + output_dir1_.mkdir(parents=True, exist_ok=True) + output_dir2_ = output_dir / "bundles" + output_dir2_.mkdir(parents=True, exist_ok=True) + + commands = [ + [ + "mrtransform", + str(fod_file), + "-warp", + str(mrtrix_warp_file), + str(workspace_dir / "FOD_WM_MNI.mif"), + "-reorient_fod", "yes", + "-force", + ], + [ + "sh2peaks", + str(workspace_dir / "FOD_WM_MNI.mif"), + str(workspace_dir / "peaks_MNI.mif"), + "-num", "3", + "-force", + ], + [ + "mrconvert", + str(workspace_dir / "peaks_MNI.mif"), + str(workspace_dir / "peaks_MNI.nii.gz"), + "-force", + ], + *[ + [ + "TractSeg", + "-i", str(workspace_dir / "peaks_MNI.nii.gz"), + "-o", str(workspace_dir / "tractseg"), + "--output_type", dtype + ] + for dtype in ("tract_segmentation", "endings_segmentation", "TOM") + ], + [ + "TractSeg", + "-i", str(workspace_dir / "peaks_MNI.nii.gz"), + "-o", str(workspace_dir / "tractseg"), + "--output_type", "tract_segmentation", + "--uncertainty", + ], + [ + "Tracking", + "-i", str(workspace_dir / "peaks_MNI.nii.gz"), + "-o", str(workspace_dir / "tractseg"), + "--nr_fibers", "5000", + ], + *[ + [ + "Tractometry", + "-i", str(workspace_dir / "tractseg" / "TOM_trackings"), + "-o", str(output_dir1_ / f"{basename}_{name}.csv"), + "-e", str(workspace_dir / "tractseg" / + "endings_segmentations"), + "-s", str(in_file), + ] + for in_file, name in zip( + scalar_map_files, scalar_map_names, strict=True + ) + ], + [ + "find", + str(workspace_dir / "tractseg" / "bundle_segmentations"), + "-maxdepth", "1", + "-type", "f", + "-name", "*.nii.gz", + "-exec", + "sh", + "-c", + f'cp "$1" "{output_dir2_}/{basename}_$(basename "$1")"', + "_", + "{}", + ";", + ], + [ + "find", + str(workspace_dir / "tractseg" / "bundle_uncertainties"), + "-maxdepth", "1", + "-type", "f", + "-name", "*.nii.gz", + "-exec", + "sh", + "-c", + (f'cp "$1" "{output_dir2_}/{basename}_rec-uncertainty_"' + f'"$(basename "$1")"'), + "_", + "{}", + ";", + ], + ] + + return commands, [ + [ + output_dir1_ / f"{basename}_{name}.csv" + for name in scalar_map_names + ], + ] diff --git a/brainprep/interfaces/utils.py b/brainprep/interfaces/utils.py index ff4dcd33..a782fe52 100644 --- a/brainprep/interfaces/utils.py +++ b/brainprep/interfaces/utils.py @@ -14,10 +14,7 @@ import gzip import shutil import socket -from collections import OrderedDict -import nibabel -import numpy as np import pandas as pd from ..decorators import ( @@ -26,6 +23,7 @@ LogRuntimeHook, OutputdirHook, PythonWrapperHook, + SignatureHook, step, ) from ..typing import ( @@ -45,136 +43,44 @@ bunched=False ), PythonWrapperHook(), - ] -) -def maskdiff( - mask1_file: File, - mask2_file: File, - output_dir: Directory, - entities: dict, - inv_mask1: bool = False, - inv_mask2: bool = False, - dryrun: bool = False) -> tuple[File]: - """ - Compute summary statistics comparing two binary masks. - - This function loads two binary mask images, verifies that they share - the same spatial dimensions and affine transformation, computes their - voxel-wise intersection, and writes a summary table containing voxel - counts and physical volumes (in mm³) for each mask and their intersection. - - Parameters - ---------- - mask1_file : File - Path to the first binary mask image. - mask2_file : File - Path to the second binary mask image. - output_dir : Directory - Directory where the defacing mask will be saved. - entities : dict - A dictionary of parsed BIDS entities including modality. - inv_mask1 : bool - If True, the first mask is inverted before comparison. This is - useful when the mask represents an exclusion region rather than an - inclusion region. Default False. - inv_mask2 : bool - If True, the second mask is inverted before comparison. This is - useful when the mask represents an exclusion region rather than an - inclusion region. Default False. - dryrun : bool - If True, skip actual computation and file writing. Default False. - - Returns - ------- - summary_file : File - Path to the generated summary TSV file. - - Raises - ------ - ValueError - If both masks have not identical shapes and affines. - """ - basename = "sub-{sub}_ses-{ses}_run-{run}_mod-T1w_defacemask".format( - **entities) - summary_file = output_dir / f"{basename}.tsv" - - if not dryrun: - - mask1_im = nibabel.load(mask1_file) - mask2_im = nibabel.load(mask2_file) - mask1 = mask1_im.get_fdata().astype(bool) - mask2 = mask2_im.get_fdata().astype(bool) - - if inv_mask1: - mask1 = ~mask1 - if inv_mask2: - mask1 = ~mask2 - - if mask1.shape != mask2.shape: - raise ValueError( - f"Mask shapes differ: {mask1.shape} vs {mask2.shape}. " - "Resampling is required." - ) - if not np.allclose(mask1_im.affine, mask2_im.affine): - raise ValueError( - "Mask affines differ. Resampling is required before " - "intersection." - ) - - intersection = np.logical_and(mask1, mask2) - voxel_volume = np.abs(np.linalg.det(mask1_im.affine[:3, :3])) - - summary_df = pd.DataFrame({ - "mask": ["mask1", "mask2", "intersection"], - "voxels": [ - mask1.sum(), - mask2.sum(), - intersection.sum(), - ], - "volume_mm3": [ - mask1.sum() * voxel_volume, - mask2.sum() * voxel_volume, - intersection.sum() * voxel_volume, - ] - }) - summary_df.to_csv(summary_file, sep="\t", index=False) - - return (summary_file, ) - - -@step( - hooks=[ - CoerceparamsHook(), - OutputdirHook(), - LogRuntimeHook( - bunched=False - ), - PythonWrapperHook(), + SignatureHook(), ] ) def copyfiles( - source_image_files: list[File], - destination_image_files: list[File], + source_files: list[File], + destination_files: list[File], output_dir: Directory, - dryrun: bool = False) -> None: + move_files: bool = False, + dryrun: bool = False, + ) -> None: """ - Copy input image files. + Copy or move input files to a specified destination. Parameters ---------- - source_image_files : list[File] - Path to the image to be copied. - destination_image_files : list[File] - Path to the locations where images will be copied. + source_files : list[File] + List of files to be copied or moved. + destination_files : list[File] + List of files representing the target locations for the copied or + moved files. output_dir : Directory - Directory where the images are copied. + The directory where the files will be copied or moved to. + move_files : bool + If True, move the input files instead of copying them. + Default False. dryrun : bool - If True, skip actual computation and file writing. Default False. + If True, skip actual computation and file writing. + Default False. """ - if not dryrun: - for src_path, dest_path in zip(source_image_files, - destination_image_files, - strict=True): + if dryrun: + return + + for src_path, dest_path in zip(source_files, + destination_files, + strict=True): + if move_files: + shutil.move(src_path, dest_path) + else: shutil.copy(src_path, dest_path) @@ -186,13 +92,17 @@ def copyfiles( bunched=False ), PythonWrapperHook(), + SignatureHook(), ] ) def movedir( source_dir: Directory, output_dir: Directory, content: bool = False, - dryrun: bool = False) -> tuple[Directory]: + copy: bool = False, + add_source_basename: bool = True, + dryrun: bool = False, + ) -> tuple[Directory]: """ Move input directory. @@ -203,9 +113,18 @@ def movedir( output_dir : Directory Directory where the folder is moved. content : bool - If True, move the content of the source directory. Default False. + If True, move the content of the source directory. + Default False. + copy : bool + If True, copy the content of the source directory. + Default False. + add_source_basename : bool + If True, add the source directory basename to output directory. Only + valid when content is False. + Default True. dryrun : bool - If True, skip actual computation and file writing. Default False. + If True, skip actual computation and file writing. + Default False. Returns ------- @@ -217,20 +136,53 @@ def movedir( ValueError If `source_dir` is not a directory. """ - if not dryrun: - if not source_dir.is_dir(): - raise ValueError( - f"Source '{source_dir}' is not a directory." - ) - if not content: - shutil.move(source_dir, output_dir / source_dir.name) + + def ensure_clean_directory(path): + if path.exists(): + if path.is_file(): + path.unlink() + else: + shutil.rmtree(path) + + if dryrun: + return (output_dir if content else output_dir / source_dir.name, ) + + if not source_dir.is_dir(): + raise ValueError( + f"Source '{source_dir}' is not a directory." + ) + + if not content: + if add_source_basename: + target_dir = output_dir / source_dir.name + else: + target_dir = output_dir + ensure_clean_directory(target_dir) + if copy: + shutil.copytree(source_dir, target_dir) + else: + shutil.move(source_dir, target_dir) + return (target_dir, ) + + items = source_dir.iterdir() + for item in items: + target = output_dir / item.name + ensure_clean_directory(target) + if item.is_dir(): + if copy: + shutil.copytree(item, target) + else: + shutil.move(item, target) else: - for item in source_dir.iterdir(): - target = output_dir / item.name - shutil.move(item, output_dir / item.name) - if not any(source_dir.iterdir()): - source_dir.rmdir() - return (output_dir if content else output_dir / source_dir.name, ) + if copy: + shutil.copy2(item, target) + else: + shutil.move(item, target) + + if not any(source_dir.iterdir()): + source_dir.rmdir() + + return (output_dir, ) @step( @@ -240,13 +192,15 @@ def movedir( bunched=False ), PythonWrapperHook(), + SignatureHook(), ] ) def ungzfile( input_file: File, output_file: File, output_dir: Directory, - dryrun: bool = False) -> tuple[File]: + dryrun: bool = False, + ) -> tuple[File]: """ Ungzip input file. @@ -259,7 +213,8 @@ def ungzfile( output_dir : Directory Directory where the unzip file is created. dryrun : bool - If True, skip actual computation and file writing. Default False. + If True, skip actual computation and file writing. + Default False. Returns ------- @@ -290,14 +245,16 @@ def ungzfile( LogRuntimeHook( bunched=False ), + SignatureHook(), ] ) def write_uuid_mapping( input_file: File, output_dir: Directory, entities: dict, - name: str = "uuid_mapping.tsv", - full_path: bool = False) -> File: + name: str = "uuid_mapping", + full_path: bool = False, + ) -> File: """ Create a TSV file that records a deterministic UUID-based mapping. @@ -318,14 +275,15 @@ def write_uuid_mapping( Name of the TSV file to write. Default is "uuid_mapping.tsv". full_path: bool If True, extract entities from the full input path rather than - only the filename. Default is False. + only the filename. + Default is False. Returns ------- output_file : File Path to the written TSV file. """ - outut_file = output_dir / f"run-{entities['run']}" / name + outut_file = output_dir / f"{name}_{entities['run']}.tsv" filename = str(input_file) if full_path else input_file.name code, short_code = make_run_id(filename) @@ -350,25 +308,30 @@ def write_uuid_mapping( bunched=False ), CommandLineWrapperHook(), + SignatureHook(), ] ) def anonfile( input_file: File, - mapping: dict[str, str]) -> tuple[list[str], File]: + derivatives_dir: Directory | None, + rawdata_dir: Directory | None, + ) -> tuple[list[str], File]: """ Anonymize a text file using sed. The function constructs a list of sed substitution expressions based on - the user-provided mapping and additional system-derived identifiers - (hostname, IP address, username). The resulting command performs - in-place anonymization of the input file. + the user-provided directory to anonumize and additional system-derived + identifiers (hostname, IP address, username). The resulting command + performs in-place anonymization of the input file. Parameters ---------- input_file : File Path to the file to anonymize. - mapping : dict[str, str] - Patterns to replace (keys) and their replacements (values). + derivatives_dir : Directory | None + Derivatives directory. + rawdata_dir : Directory | None + Raw data directory. Returns ------- @@ -377,21 +340,20 @@ def anonfile( output_file : File Path to the anonymized file. """ - mapping = OrderedDict(mapping) hostname = socket.gethostname() - mapping.update( - OrderedDict({ - hostname: "HOSTNAME", - socket.gethostbyname(hostname): "X.X.X.X", - getpass.getuser(): "USER", - }) - ) + mapping = [ + (str(derivatives_dir), "DERIVATIVES"), + (str(rawdata_dir), "RAWDATA"), + (hostname, "HOSTNAME"), + (socket.gethostbyname(hostname), "X.X.X.X"), + (getpass.getuser(), "USER"), + ] patterns = [] - for old, new in mapping.items(): + for old, new in mapping: old_esc = old.replace("/", r"\/") new_esc = new.replace("/", r"\/") - patterns.extend(["-e", f"'s/{old_esc}/{new_esc}/g'"]) + patterns.extend(["-e", f"s/{old_esc}/{new_esc}/g"]) command = [ "sed", *patterns, @@ -399,3 +361,41 @@ def anonfile( ] return command, (input_file, ) + + +@step( + hooks=[ + CoerceparamsHook(), + LogRuntimeHook( + bunched=False + ), + CommandLineWrapperHook(), + SignatureHook(), + ] +) +def htmlmin( + input_file: File, + ) -> File: + """ + Minify HTML code. + + Removes unnecessary whitespace, comments, and other elements. + If a path to an HTML file is given, the operations are performed inplace. + + Parameters + ---------- + input_file : File + The HTML code to be minified. + + Returns + ------- + input_file : File + The minified HTML code. + """ + command = [ + "minify", + "-o", str(input_file), + str(input_file), + ] + + return command, (input_file, ) diff --git a/brainprep/reporting/data/base.html b/brainprep/reporting/data/base.html index 7a31d836..65b06ffd 100644 --- a/brainprep/reporting/data/base.html +++ b/brainprep/reporting/data/base.html @@ -74,7 +74,7 @@
    -

    Home

    +

    Home

    -
    @@ -125,11 +124,67 @@

    Home

    crossorigin="anonymous" referrerpolicy="no-referrer"> + + diff --git a/brainprep/reporting/data/body.html b/brainprep/reporting/data/body.html index 9f70e87b..3cf4197a 100644 --- a/brainprep/reporting/data/body.html +++ b/brainprep/reporting/data/body.html @@ -5,11 +5,11 @@ {% for workflow in workflows %}
  • - {{ workflow.name }} + {{ workflow.name }}
  • {% endfor %}
  • - About + About
  • {% endblock nav %} @@ -20,81 +20,134 @@
    -
    -

    Description

    - Data were analyzed using BrainPrep - (version={{ version }}). -

    -
    
    -            {{ docstring }}
    -        
    -

    -
    - {% for workflow in workflows %}

    {{ workflow.name }}

    + + {% if workflow.summary %} +
    +

    {{ workflow.summary }}

    +
    + {% endif %} +
    -
    - {% if workflow.content and workflow.content|length == 1 %} - No image provided. - {% if workflow.overlay %} -
    - No overlay found. + + {% if workflow.images %} + {% for name, data in workflow.images|dictsort %} +
    + +
    + {{ name }} +
    +
    + {% for image in data.record %} +
    + No image provided. + {% if data.overlays and data.overlays[loop.index0] %} +
    + No overlay found. +
    + {% endif %} +
    + {% endfor %} +
    + {% endfor %} + {% endif %} + + {% if workflow.carousels %} + {% for name, data in workflow.carousels|dictsort %} +
    + +
    + {{ name }} +
    +
    + {% set carousel_index = loop.index0 %} +
    +
    + +

    + +

    + +
    + {% for image in data.record %} + + {% endfor %}
    - {% endif %} - {% elif workflow.content and workflow.content|length > 1 %} -
    - -

    - -

    - +
    + {% endfor %} + {% endif %} + + {% if workflow.scatters %} + {% for name, data in workflow.scatters|dictsort %} +
    + +
    + {{ name }} +
    +
    +
    +
    + +
    + + {% if data.with_img %} +
    +

    + Click a dot to see a specific {{ name }}'s image if available. +

    + +
    + {% endif %}
    - {% for image in workflow.content %} - +
    + {% endfor %} + {% endif %} + + {% if workflow.tables %} + {% for name, data in workflow.tables|dictsort %} +
    + +
    + {{ name }} +
    +
    + {% for table in data.record %} + {{ table }} {% endfor %} - {% endif %} -
    - {% if workflow.tables %} -
    - - Tables - - {% for table in workflow.tables %} - {{ table }} - {% endfor %} -
    - {% endif %} -
    + + {% endfor %} + {% endif %} +
    {% endfor %} @@ -102,7 +155,18 @@

    About

      -
    • Date preprocessed:
    • +
    • + Data were analyzed using BrainPrep (version={{ version }}). +
    • +
    • + Preprocessing date: +
    • +
    • + Reference: Grigis, Antoine et al., BrainPrep source code (Version {{ version }}), https://github.com/brainprepdesk/brainprep. +
    • +
    • + Methods: Methods description are provided with the express intention that users can copy and paste them into their manuscripts. +
    diff --git a/brainprep/reporting/data/script.js b/brainprep/reporting/data/script.js index 1d1e2899..21cb7ff2 100644 --- a/brainprep/reporting/data/script.js +++ b/brainprep/reporting/data/script.js @@ -190,4 +190,241 @@ class Carousel { } }); } -} +} + + + +/** + * Initializes a reusable and interactive Canvas scatter plot. + * + * @param {Object} options - Chart configuration options. + * @param {string} options.canvasId - The ID of the element. + * @param {Array} options.data - Array of data points {x, y, img}. + * @param {Object} [options.limits=null] - Axis boundaries {minX, maxX, minY, maxY}. + * @param {Object} [options.domTargets] - HTML element IDs for click actions {imgId, placeholderId}. + * @param {string} [options.xLabel=""] - Title text for the X axis. + * @param {string} [options.yLabel=""] - Title text for the Y axis. + * @param {number} [options.radius=8] - Dots radius in pixels. + * @param {number} [options.padding=50] - Inner chart padding in pixels. + */ +function createInteractiveChart({ + canvasId, + data, + limits = null, + domTargets = { imgId: 'clicked-image', placeholderId: 'placeholder' }, + xLabel = "", + yLabel = "", + radius = 8, + padding = 50 +}) { + const canvas = document.getElementById(canvasId); + if (!canvas) return console.error(`Canvas with ID "${canvasId}" not found.`); + + const ctx = canvas.getContext('2d'); + + // Clean Auto-scaling with Epsilon protection + let minX, maxX, minY, maxY; + if (limits) { + ({ minX, maxX, minY, maxY } = limits); + } else { + const xValues = data.map(p => p.x); + const yValues = data.map(p => p.y); + minX = Math.min(...xValues); + maxX = Math.max(...xValues); + minY = Math.min(...yValues); + maxY = Math.max(...yValues); + + const EPSILON = 0.001; + if (Math.abs(maxX - minX) < EPSILON) { + const padX = maxX - minX === 0 ? 1 : Math.abs(maxX - minX) * 2; + console.log("padX=" + padX); + minX -= padX; maxX += padX; + } + if (Math.abs(maxY - minY) < EPSILON) { + const padY = maxY - minY === 0 ? 1 : Math.abs(maxY - minY) * 2; + console.log("padY=" + padY); + minY -= padY; maxY += padY; + } + console.log("minX=" + minX+ ", maxX=" + maxX); + console.log("minY=" + minY+ ", maxY=" + maxY); + } + + // Pre-calculate and store canvas coordinates for each data point + const points = data.map(p => ({ + ...p, + canvasX: padding + ((p.x - minX) / (maxX - minX)) * (canvas.width - 2 * padding), + canvasY: canvas.height - padding - ((p.y - minY) / (maxY - minY)) * (canvas.height - 2 * padding) + })); + + function draw() { + // Clear canvas for fresh render + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // 1. Gridlines and Text Styles + ctx.strokeStyle = '#e0e0e0'; + ctx.lineWidth = 1; + ctx.fillStyle = '#333'; + ctx.font = '12px Arial'; + + // X-Axis Grid (Vertical lines) + ctx.textAlign = 'center'; + for (let x = minX; x <= maxX; x++) { + const cx = padding + ((x - minX) / (maxX - minX)) * (canvas.width - 2 * padding); + ctx.beginPath(); + ctx.moveTo(cx, padding); + ctx.lineTo(cx, canvas.height - padding); + ctx.stroke(); + // ctx.fillText(x, cx, canvas.height - padding + 20); + } + + // Y-Axis Grid (Horizontal lines) + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + for (let y = minY; y <= maxY; y += 10) { + const cy = canvas.height - padding - ((y - minY) / (maxY - minY)) * (canvas.height - 2 * padding); + ctx.beginPath(); + ctx.moveTo(padding, cy); + ctx.lineTo(canvas.width - padding, cy); + ctx.stroke(); + // ctx.fillText(y, padding - 10, cy); + } + + // 2. Main Axes Lines + ctx.strokeStyle = '#333'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(padding, padding); + ctx.lineTo(padding, canvas.height - padding); + ctx.lineTo(canvas.width - padding, canvas.height - padding); + ctx.stroke(); + + // 3. Draw Axis Legends + ctx.fillStyle = '#111'; + ctx.font = 'bold 12px Arial'; + + if (xLabel) { + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillText(xLabel, padding + (canvas.width - 2 * padding) / 2, canvas.height - padding + 26); + } + + if (yLabel) { + ctx.save(); + ctx.translate(padding - 35, padding + (canvas.height - 2 * padding) / 2); + ctx.rotate(-Math.PI / 2); + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + ctx.fillText(yLabel, 0, 0); + ctx.restore(); + } + + // 4. Render Data Points + points.forEach(p => { + ctx.beginPath(); + ctx.arc(p.canvasX, p.canvasY, radius, 0, 2 * Math.PI); + ctx.fillStyle = 'rgba(33, 150, 243, 0.8)'; + ctx.fill(); + ctx.strokeStyle = '#0d47a1'; + ctx.lineWidth = 1.5; + ctx.stroke(); + }); + } + + // Native Click Event Listener + canvas.addEventListener('click', function(event) { + // Get precise mouse cursor position relative to the canvas bounding box + const rect = canvas.getBoundingClientRect(); + const mouseX = event.clientX - rect.left; + const mouseY = event.clientY - rect.top; + + // Detect if the mouse cursor coordinates collide with any data point + const foundPoint = points.find(p => { + const distance = Math.sqrt((mouseX - p.canvasX) ** 2 + (mouseY - p.canvasY) ** 2); + return distance <= radius + 4; // Includes a 4-pixel tolerance margin + }); + + // Update DOM elements if a point is clicked successfully + const popup = document.getElementById(domTargets.popupId); + if (foundPoint && domTargets) { + const imgElement = document.getElementById(domTargets.imgId); + const placeholder = document.getElementById(domTargets.placeholderId); + + if (imgElement) { + imgElement.src = foundPoint.img; + imgElement.style.display = 'inline-block'; + } + if (placeholder) { + placeholder.style.display = 'none'; + } + if (popup) { + popup.innerHTML = `Subject: ${foundPoint.sub}
    Session: ${foundPoint.ses}
    Run: ${foundPoint.run}`; + popup.style.display = 'block'; + popup.style.left = (event.clientX + 10 - getDetailsLeftPosition()) + 'px'; + popup.style.top = (event.clientY + 10 + window.scrollY - getDetailsTopPosition()) + 'px'; + } + } else { + // Hide popup if no point is found + if (popup) { + popup.style.display = 'none'; + } + } + }); + + // Function to get the left position of the details element + function getDetailsLeftPosition() { + const detailsElement = canvas.closest('.custom-details'); + if (detailsElement) { + const rect = detailsElement.getBoundingClientRect(); + return rect.left + window.scrollX; + } + return 0; + } + + // Function to get the top position of the details element + function getDetailsTopPosition() { + const detailsElement = canvas.closest('.custom-details'); + if (detailsElement) { + const rect = detailsElement.getBoundingClientRect(); + return rect.top + window.scrollY; + } + return 0; + } + + // Hide popup when clicking elsewhere + document.addEventListener('click', function(event) { + const popup = document.getElementById(domTargets.popupId); + if (popup && !canvas.contains(event.target)) { + popup.style.display = 'none'; + } + }); + + // Initial call to render the chart + draw(); +} + + +/** + * Scrolls to a specified section on the page, accounting for the height of a fixed navbar. + * This function smoothly scrolls the page to the top of the specified section, adjusting + * the scroll position to ensure the section is visible below the fixed navbar. + * + * @param {string} sectionId - The ID of the section to scroll to. + * + * @example + * // Scroll to the section with the ID 'mySection' + * scrollToSection('mySection'); + */ +function scrollToSection(sectionId) { + const section = document.getElementById(sectionId); + if (section) { + // Calculate the position to scroll to, accounting for the navbar height + const navbarHeight = document.querySelector(".site-navbar").offsetHeight; + const sectionPosition = section.getBoundingClientRect().top + window.scrollY - navbarHeight; + + // Scroll to the calculated position + window.scrollTo({ + top: sectionPosition, + behavior: "smooth" + }); + } +} diff --git a/brainprep/reporting/data/style.css b/brainprep/reporting/data/style.css index 6a36b18d..3092ae00 100644 --- a/brainprep/reporting/data/style.css +++ b/brainprep/reporting/data/style.css @@ -440,8 +440,8 @@ a { .image { position: relative; height: auto; - width: 70%; - margin-left: 15%; + width: 60%; + margin-left: 20%; } .image .overlay { @@ -467,7 +467,6 @@ a { overflow-y: scroll; } - .btn { background-color: #3498db; color: white; @@ -481,3 +480,103 @@ a { .btn:focus { background-color: #2980b9; } + +.chart-section { + margin-bottom: 50px; + display: flex; + flex-direction: column; + align-items: center; +} +.canvas-container { + position: relative; + display: inline-block; + margin-bottom: 20px; + background: #ffffff; + border: 1px solid #ddd; + box-shadow: 0 2px 4px rgba(0,0,0,0.05); +} +.canvas-element { + cursor: pointer; + display: block; +} +.image-viewer { + margin-top: 20px; + padding: 20px; + border: 2px dashed #bbb; + min-height: 200px; + background: #f9f9f9; + width: 100%; + max-width: 800px; + box-sizing: border-box; + display: flex; + justify-content: center; + align-items: center; +} +.preview-image { + max-width: 100%; + height: auto; + display: block; + border-radius: 6px; + box-shadow: 0 2px 5px rgba(0,0,0,0.15); +} +.placeholder-text { + color: #666; +} + +.nav-link { + color: #007bff; + text-decoration: none; + padding: 5px 10px; + border-radius: 3px; + transition: background-color 0.3s ease; + cursor: pointer; +} + +.nav-link:hover { + background-color: #007bff; + color: white; +} + +.popup { + position: absolute; + background-color: white; + border: 1px solid black; + padding: 10px; + display: none; + z-index: 1000; +} + +.custom-details { + border: 1px solid #ccc; + border-radius: 5px; + padding: 0; + margin: 10px; + position: relative; +} + +.custom-summary { + padding: 10px; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + list-style: none; + font-weight: bold; + background-color: #f5f5f5; + border-radius: 5px; +} + +.custom-summary::after { + content: "+"; + font-size: 20px; + transition: transform 0.3s ease; +} + +.custom-details[open] .custom-summary::after { + content: "-"; +} + +.custom-content { + padding: 10px; + background-color: #fff; +} diff --git a/brainprep/reporting/html_reporting.py b/brainprep/reporting/html_reporting.py index c9cd62e1..7f352b5f 100644 --- a/brainprep/reporting/html_reporting.py +++ b/brainprep/reporting/html_reporting.py @@ -10,11 +10,15 @@ Module that implements a HTML reporting tool. """ +import json import uuid from html import escape from pathlib import Path from typing import Self +from ..typing import ( + File, +) from .utils import ( dataframe_to_html, inject_with_jinja, @@ -48,9 +52,11 @@ class HTMLReport: html : str The HTML content to be rendered. width : int - Width of the display area in pixels. Default 800. + Width of the display area in pixels. + Default 800. height : int - Height of the display area in pixels. Default 800. + Height of the display area in pixels. + Default 800. Examples -------- @@ -65,7 +71,8 @@ def __init__( self, html: str, width: int = 800, - height: int = 800) -> None: + height: int = 800, + ) -> None: self.html = html self.width = width self.height = height @@ -75,7 +82,8 @@ def __init__( def resize( self, width: int, - height: int) -> Self: + height: int, + ) -> Self: """ Resize the document displayed. @@ -97,16 +105,19 @@ def resize( def get_iframe( self, width: int | None, - height: int | None) -> str: + height: int | None, + ) -> str: """ Get the document wrapped in an inline frame. Parameters ---------- width: int | None - Width of the inline frame. Default None. + Width of the inline frame. + Default None. height: int | None - Height of the inline frame. Default None. + Height of the inline frame. + Default None. Returns ------- @@ -145,7 +156,8 @@ def _repr_html_(self) -> str: def _repr_mimebundle_( self, include=None, - exclude=None) -> dict: + exclude=None, + ) -> dict: """ Return html representation of the plot. @@ -163,7 +175,8 @@ def __str__(self): def save_as_html( self, - file_name: str) -> None: + file_name: str, + ) -> None: """ Save the plot in an HTML file, that can later be opened in a browser. @@ -177,10 +190,10 @@ def save_as_html( def generate_qc_report( title: str, - docstring: str, version: str, date: str, - data: list[dict]) -> HTMLReport: + data: list[dict | File], + ) -> HTMLReport: """ Generate a quality control (QC) report as an interactive HTML document. @@ -192,19 +205,29 @@ def generate_qc_report( ---------- title : str The title displayed at the top of the report. - docstring : str - A descriptive introduction or summary of the report's purpose. version : str Version identifier for the report or associated software. date : str Timestamp indicating when the report was generated. - data : list[dict] - A list of dictionaries, each representing a workflow step. Each - dictionary must contain the following keys: - - name (str): Title of the step. - - content (Path or list of Path): Image(s) to display. - - overlay (Path): Image(s) to show on hover. - - tables (DataFrame or list of DataFrame): Tabular data to include. + data : list[dict | File] + A list of dictionaries or JSON files containing dictionaries, each + representing a workflow step. Each dictionary must contain the + following keys: + + - name : str - Title of the step. + - summary : str - A HTML string to be be displayed. + - images : dict | None - A dictionary containing configurations for + image plots. If provided, the dictionary must follow this specific + schema. + - carousels : dict | None - A dictionary containing configurations for + a carousel plots. If provided, the dictionary must follow this + specific schema. + - tables : dict | None - A dictionary containing configurations for + table plots. If provided, the dictionary must follow this specific + schema. + - scatters : dict | None - A dictionary containing configurations for + interactive scatter plots. If provided, the dictionary must follow + this specific schema. Returns ------- @@ -213,8 +236,48 @@ def generate_qc_report( Notes ----- - - Images are converted to base64 for inline embedding. - - Tables are rendered as HTML using `dataframe_to_html`. + Images are converted to base64 for inline embedding. + + Tables are rendered as HTML using `dataframe_to_html`. + + The `images` dictionary must follow this specific schema: + + - "chart_name": + - "record": A list of strings representing the images to display. + - "overlays": A list of strings or None, representing the images to + show over the main images. This can also be None. + - "labels": A list of strings or None, representing the text labels + for each image. This can also be None. + + The `carousels` dictionary must follow this specific schema: + + - "chart_name": + - "record": A list of strings representing the images to include in + the carousel. + - "labels": A list of strings or None, representing the text labels + for each image. This can also be None. + + The `tables` dictionary must follow this specific schema: + + - "chart_name": + - "record": A list of DataFrames representing the tabular data to + include. + - "labels": A list of strings or None, representing the text labels + for each table. This can also be None. + + The `scatters` dictionary must follow this specific schema: + + - "chart_name": + - "record": A list of dictionaries representing the points in the + scatter plot. Each dictionary must contain the keys 'x', 'y', and + 'img'. + - "x_label": A string representing the text label displayed along the + X-axis of the scatter plot. + - "y_label": A string representing the text label displayed along the + Y-axis of the scatter plot. + - "with_img": A boolean indicating whether to display images + associated with each point. If False, only the points will be + displayed. Examples -------- @@ -238,48 +301,61 @@ def generate_qc_report( """ template_path = Path(__file__).parent / "data" / "body.html" css_path = Path(__file__).parent / "data" / "style.css" + with css_path.open(encoding="utf-8") as css_file: css = css_file.read() js_path = Path(__file__).parent / "data" / "script.js" with js_path.open(encoding="utf-8") as js_file: js = js_file.read() - unique_id = str(uuid.uuid4()).replace("-", "") + + data = [ + dict_or_file + if isinstance(dict_or_file, dict) + else json.load(dict_or_file.open()) + for dict_or_file in data + ] + for counter, item in enumerate(data): item["id"] = counter - content = item.get("content") - overlay = item.get("overlay") - tables = item.get("tables") - if content is not None: - if not isinstance(content, (tuple, list)): - content = [content] - item["content"] = [ - png_image_to_base64(img) for img in content - ] - if overlay is not None: - if not isinstance(overlay, (tuple, list)): - overlay = [overlay] - item["overlay"] = [ - png_image_to_base64(img) for img in overlay - ] - if tables is not None: - if not isinstance(tables, (tuple, list)): - tables = [tables] - item["tables"] = [ - dataframe_to_html( - tab, - precision=2, - header=True, - index=False, - sparsify=False, - ) for tab in tables - ] + + if "images" in item: + for key in item["images"]: + item["images"][key]["record"] = [ + png_image_to_base64(img) + for img in item["images"][key]["record"] + ] + if item["images"][key].get("overlays") is not None: + item["images"][key]["overlays"] = [ + png_image_to_base64(img) + if img is not None else None + for img in item["images"][key]["overlays"] + ] + + if "carousels" in item: + for key in item["carousels"]: + item["carousels"][key]["record"] = [ + png_image_to_base64(img) + for img in item["carousels"][key]["record"] + ] + + if "tables" in item: + for key in item["tables"]: + item["tables"][key]["record"] = [ + dataframe_to_html( + tab, + precision=2, + header=True, + index=False, + sparsify=False, + ) for tab in item["tables"][key]["record"] + ] + html = inject_with_jinja( template_file=template_path, css=css, js=js, - uuid=unique_id, + uuid=str(uuid.uuid4()).replace("-", ""), title=title, - docstring=docstring, version=version, date=date, workflows=data, diff --git a/brainprep/reporting/rst_reporting.py b/brainprep/reporting/rst_reporting.py index 5ac7aab6..a80cc018 100644 --- a/brainprep/reporting/rst_reporting.py +++ b/brainprep/reporting/rst_reporting.py @@ -10,28 +10,19 @@ Module that implements a RST reporting tool. """ -import datetime import inspect -import platform import textwrap -from collections.abc import Callable from pathlib import Path from typing import ( Any, Self, ) -from .._version import __version__ -from ..config import ( - DEFAULT_OPTIONS, - brainprep_options, -) from ..typing import ( File, ) from ..utils import ( Bunch, - print_title, ) @@ -71,6 +62,10 @@ class SingletonReport(type): >>> class Report(metaclass=SingletonReport): ... def __init__(self): ... self._registry = {} + ... self._commands = {} + ... def clear(self): + ... self._registry = {} + ... self._commands = {} >>> r1 = Report() >>> r2 = Report() @@ -83,7 +78,8 @@ class SingletonReport(type): def __call__( cls: type[Self], *args: Any, - **kwargs: Any) -> Self: + **kwargs: Any, + ) -> Self: """ Return the singleton instance of `SingletonReport`. @@ -107,8 +103,7 @@ def __call__( ) inst = cls._instance if not is_reloadable: - inst._count = 0 - inst._registry.clear() + inst.clear() if is_increment: inst._count += 1 inst._reloadable = is_reloadable @@ -150,6 +145,8 @@ class RSTReport(metaclass=SingletonReport): ---------- _registry : Bunch Internal storage for all registered report data. + _commands : Bunch + Internal storage for all registered commands. _str_fields : tuple[str] Allowed string fields. @@ -181,12 +178,14 @@ class RSTReport(metaclass=SingletonReport): """ _registry: Bunch = Bunch() + _commands: Bunch = Bunch() _str_fields: tuple[str] = ("module", "trace", "description") def __init__( self, reloadable: bool = False, - increment: bool = False) -> None: + increment: bool = False, + ) -> None: self._reloadable = reloadable self._increment = increment self._count = 0 @@ -195,7 +194,8 @@ def register( self, identifier: str, name: str, - data: str | Bunch) -> None: + data: str | Bunch, + ) -> None: """ Add a new data entry to the report under a given identifier and name. @@ -220,8 +220,14 @@ def register( if identifier not in self._registry: self._registry[identifier] = Bunch() if name in self._registry[identifier]: + items_str = [ + f"- {name_}\n" + for name_ in self._registry[identifier] + ] raise ValueError( - "Duplicated name in registry." + f"Duplicated name in registry: {name}\n" + f">> {identifier}\n" + f"{''.join(items_str)}" ) if not (isinstance(data, Bunch) or (isinstance(data, str) and name in self._str_fields) @@ -231,12 +237,37 @@ def register( ) self._registry[identifier][name] = data + def register_command( + self, + cmd: str, + ) -> None: + """ + Add a new command entry to the report. + + Parameters + ---------- + cmd: str + The command. + + Raises + ------ + ValueError + If duplicated identifier found. + """ + identifier = f"cmd{len(self._commands)}" + if identifier in self._commands: + raise ValueError( + "Duplicated identifier in commands." + ) + self._commands[identifier] = cmd + def __str__(self): return repr(self._registry) def save_as_rst( self, - file_name: File) -> None: + file_name: File, + ) -> None: """ Save the report content to a reStructuredText (.rst) file. @@ -265,9 +296,37 @@ def save_as_rst( report += "\n" Path(file_name).write_text(report) + def save_commands_as_rst( + self, + file_name: File, + ) -> None: + """ + Save the commands list to a reStructuredText (.rst) file. + + Parameters + ---------- + file_name: File + Path to the RST file used for saving. + """ + report = "" + for cmd in self._commands.values(): + report += f"{cmd}\n" + Path(file_name).write_text(report) + + def clear( + self, + ) -> None: + """ + Clear internal storage and counter. + """ + self._count = 0 + self._registry.clear() + self._commands.clear() + def trace_module_calls( - root_module_names: tuple[str] = ("workflow", "interfaces")) -> str: + root_module_names: tuple[str] = ("workflow", "interfaces"), + ) -> str: """ Return the trace of function calls from the specified module and its submodules. diff --git a/brainprep/reporting/utils.py b/brainprep/reporting/utils.py index 2759db80..d882d80d 100644 --- a/brainprep/reporting/utils.py +++ b/brainprep/reporting/utils.py @@ -24,8 +24,10 @@ def inject_with_jinja( template_file: File, - **kwargs: Any) -> str: - """ Render Jinja template given context and write it to an output file. + **kwargs: Any, + ) -> str: + """ + Render Jinja template given context and write it to an output file. Parameters ---------- @@ -50,8 +52,10 @@ def inject_with_jinja( def dataframe_to_html( df: pd.DataFrame, precision: int, - **kwargs: Any) -> str: - """ Make HTML table from provided dataframe. + **kwargs: Any, + ) -> str: + """ + Make HTML table from provided dataframe. Removes HTML5 non-compliant attributes (ex: `border`). @@ -76,8 +80,10 @@ def dataframe_to_html( def png_image_to_base64( - image_path: File) -> str: - """ Embed an image. + image_path: File, + ) -> str: + """ + Embed an image. Parameters ---------- @@ -89,6 +95,7 @@ def png_image_to_base64( embed: str Binary image string. """ + image_path = Path(image_path) assert image_path.suffix == ".png" encoded_string = base64.b64encode( image_path.read_bytes() diff --git a/brainprep/resources/MNI152_T1_1mm_brain.nii.gz b/brainprep/resources/MNI152_T1_1mm_brain.nii.gz index 3bd6af20..0bd66f5c 100644 Binary files a/brainprep/resources/MNI152_T1_1mm_brain.nii.gz and b/brainprep/resources/MNI152_T1_1mm_brain.nii.gz differ diff --git a/brainprep/resources/MNI152_T1_2mm_brain.nii.gz b/brainprep/resources/MNI152_T1_2mm_brain.nii.gz new file mode 100755 index 00000000..3fda6c09 Binary files /dev/null and b/brainprep/resources/MNI152_T1_2mm_brain.nii.gz differ diff --git a/brainprep/resources/MNI152_T2_1mm_brain.nii.gz b/brainprep/resources/MNI152_T2_1mm_brain.nii.gz new file mode 100644 index 00000000..8f61c183 Binary files /dev/null and b/brainprep/resources/MNI152_T2_1mm_brain.nii.gz differ diff --git a/brainprep/resources/MNI152_T2_2mm_brain.nii.gz b/brainprep/resources/MNI152_T2_2mm_brain.nii.gz new file mode 100755 index 00000000..84dcb0e3 Binary files /dev/null and b/brainprep/resources/MNI152_T2_2mm_brain.nii.gz differ diff --git a/brainprep/resources/MNI_BigFov_to_MNI.mat b/brainprep/resources/MNI_BigFov_to_MNI.mat new file mode 100644 index 00000000..fcb756da --- /dev/null +++ b/brainprep/resources/MNI_BigFov_to_MNI.mat @@ -0,0 +1,4 @@ +1 -0 -0 -50 +0 1 -0 -50 +0 0 1 -100 +0 0 0 1 diff --git a/brainprep/resources/MNI_to_MNI_BigFoV.mat b/brainprep/resources/MNI_to_MNI_BigFoV.mat new file mode 100644 index 00000000..063ab124 --- /dev/null +++ b/brainprep/resources/MNI_to_MNI_BigFoV.mat @@ -0,0 +1,4 @@ +1 0 0 50 +0 1 0 50 +0 0 1 100 +0 0 0 1 diff --git a/brainprep/tests/test_docstring.py b/brainprep/tests/test_docstring.py index 3a59fb4b..4c2e015d 100644 --- a/brainprep/tests/test_docstring.py +++ b/brainprep/tests/test_docstring.py @@ -12,19 +12,36 @@ import pkgutil import unittest +from brainprep.reporting import RSTReport -def load_tests(loader, tests, ignore): - for _, module_name, ispkg in pkgutil.walk_packages( - brainprep.__path__, - brainprep.__name__ + "."): - module = importlib.import_module(module_name) - tests.addTests( - doctest.DocTestSuite( + +def doctest_setup(test): + test.globs["report"] = RSTReport() + + +class TestDocString(unittest.TestCase): + + def test_doctests(self): + result = unittest.TestResult() + n_tests = 0 + for _, module_name, ispkg in pkgutil.walk_packages( + brainprep.__path__, + brainprep.__name__ + "."): + module = importlib.import_module(module_name) + suite = doctest.DocTestSuite( module, + setUp=doctest_setup, optionflags=doctest.NORMALIZE_WHITESPACE | doctest.ELLIPSIS ) - ) - return tests + suite(result) + n_tests += 1 + if not result.wasSuccessful(): + report = "" + n_errors = 0 + for test, err in result.failures + result.errors: + report += f"\nTest fail: {test}\n>> {err}" + n_errors += 1 + self.fail(f"Error in doctests: {n_errors}/{n_tests}\n{report}") if __name__ == "__main__": diff --git a/brainprep/tests/test_workflow.py b/brainprep/tests/test_workflow.py index 204e27aa..d765b22d 100644 --- a/brainprep/tests/test_workflow.py +++ b/brainprep/tests/test_workflow.py @@ -1,5 +1,5 @@ ########################################################################## -# NSAp - Copyright (C) CEA, 2021 - 2025 +# NSAp - Copyright (C) CEA, 2021 - 2026 # Distributed under the terms of the CeCILL-B license, as published by # the CEA-CNRS-INRIA. Refer to the LICENSE file or to # http://www.cecill.info/licences/Licence_CeCILL-B_V1-en.html @@ -7,15 +7,56 @@ ########################################################################## +from concurrent.futures import ProcessPoolExecutor +import subprocess import unittest import runpy from pathlib import Path +from brainprep.reporting import RSTReport + class TestGalleryExamples(unittest.TestCase): def setUp(self): self.examples_dir = Path(__file__).parent.parent.parent / "examples" + self.report = RSTReport() + + @staticmethod + def run_cmd(cmd): + try: + _ = subprocess.check_call( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + return None + except subprocess.CalledProcessError as e: + return f"Command failed: {' '.join(cmd)}" + + def _test_example(self, script_path): + return runpy.run_path(str(script_path)) + + def _test_interface_commands(self, env): + outdir = Path(env["outdir"]) + commands, commands_files = [], [] + for commands_file in outdir.rglob("commands_*.rst"): + commands.extend( + commands_file.read_text().splitlines() + ) + commands_files.append(f"\n - {commands_file}") + commands = [[*cmd.split(" "), "--dryrun"] for cmd in commands] + print(f"Parsed: {''.join(commands_files)}") + print(f"Interface commands: {len(commands)}") + + failures = [] + with ProcessPoolExecutor(max_workers=20) as pool: + for msg in pool.map(TestGalleryExamples.run_cmd, commands): + if msg is not None: + failures.append(msg) + if failures: + self.fail("\n".join(failures)) def test_html_reporting(self): script_path = ( @@ -39,7 +80,8 @@ def test_quality_assurance(self): "workflows" / "plot_quality_assurance.py" ) - runpy.run_path(str(script_path)) + env = self._test_example(script_path) + self._test_interface_commands(env) def test_defacing(self): script_path = ( @@ -47,7 +89,8 @@ def test_defacing(self): "workflows" / "plot_defacing.py" ) - runpy.run_path(str(script_path)) + env = self._test_example(script_path) + self._test_interface_commands(env) def test_quasiraw(self): script_path = ( @@ -55,7 +98,8 @@ def test_quasiraw(self): "workflows" / "plot_quasiraw.py" ) - runpy.run_path(str(script_path)) + env = self._test_example(script_path) + self._test_interface_commands(env) def test_sbm(self): script_path = ( @@ -63,7 +107,8 @@ def test_sbm(self): "workflows" / "plot_sbm.py" ) - runpy.run_path(str(script_path)) + env = self._test_example(script_path) + self._test_interface_commands(env) def test_vbm(self): script_path = ( @@ -71,7 +116,8 @@ def test_vbm(self): "workflows" / "plot_vbm.py" ) - runpy.run_path(str(script_path)) + env = self._test_example(script_path) + self._test_interface_commands(env) def test_fmriprep(self): script_path = ( @@ -79,7 +125,8 @@ def test_fmriprep(self): "workflows" / "plot_fmriprep.py" ) - runpy.run_path(str(script_path)) + env = self._test_example(script_path) + self._test_interface_commands(env) if __name__ == "__main__": diff --git a/brainprep/utils/__init__.py b/brainprep/utils/__init__.py index 3298c000..069feff0 100644 --- a/brainprep/utils/__init__.py +++ b/brainprep/utils/__init__.py @@ -12,40 +12,48 @@ from .bunch import Bunch from .color import ( + print_call, print_command, print_deprecated, print_error, print_info, print_result, + print_stdout, print_subtitle, print_title, print_warn, ) from .utils import ( + bvecbval_from_file, coerce_to_list, coerce_to_path, find_first_occurrence, find_stack_level, make_run_id, parse_bids_keys, + sbref_from_file, sidecar_from_file, ) __all__ = [ "Bunch", + "bvecbval_from_file", "coerce_to_list", "coerce_to_path", "find_first_occurrence", "find_stack_level", "make_run_id", "parse_bids_keys", + "print_call", "print_command", "print_deprecated", "print_error", "print_info", "print_result", + "print_stdout", "print_subtitle", "print_title", "print_warn", + "sbref_from_file", "sidecar_from_file", ] diff --git a/brainprep/utils/color.py b/brainprep/utils/color.py index fccde86c..deb15dbc 100644 --- a/brainprep/utils/color.py +++ b/brainprep/utils/color.py @@ -30,20 +30,35 @@ } -def print_color(category: str, text: str) -> None: - """ Print text. +def print_category_color(category: str, text: str) -> None: + """ + Print a formatted message to the console using the color associated + with a given category. + + The output style depends on the global brainprep options: + + - If ``verbose`` is False, only messages with category ``"deprecated"`` + are printed. + - If ``no_color`` is True, the message is printed without color styling. Parameters ---------- - category: str - a category to select the preconfigured color. - text: str - text to print. + category : str + A message category used to select the corresponding color style + from the ``fg_colors`` table. Examples include ``"info"``, + ``"warning"``, ``"error"``, or ``"deprecated"``. + text : str + The message content to display. Raises ------ ValueError - If an invalid category is used. + If ``category`` is not defined in the ``fg_colors`` table. + + Notes + ----- + The printed message follows the format: [category] - text, with optional + color styling applied through ``rich``. """ console = Console() opts = brainprep_options.get() @@ -64,6 +79,41 @@ def print_color(category: str, text: str) -> None: console.print(line) +def print_color(color: str, text: str, end: str = "\n") -> None: + r""" + Print a message to the console using a given color. + + The output style depends on the global brainprep options: + + - If ``verbose`` is False, no message is printed. + - If ``no_color`` is True, the message is printed without color styling. + + Parameters + ---------- + color : str + Name of the color style to apply. + text : str + The message to display. + end : str + String appended after the message. + Default ``"\\n"``. + + Notes + ----- + Color styling is handled by ``rich``. + """ + console = Console() + opts = brainprep_options.get() + verbose = opts.get("verbose", DEFAULT_OPTIONS["verbose"]) + with_color = not opts.get("no_color", DEFAULT_OPTIONS["no_color"]) + + if not verbose: + return + + style = color if with_color else None + console.print(Text(text, style=style), end=end) + + def print_title(title: str) -> None: """ Print title. @@ -72,7 +122,7 @@ def print_title(title: str) -> None: title: str text to print. """ - print_color("title", title) + print_category_color("title", title) def print_subtitle(subtitle: str) -> None: @@ -83,7 +133,7 @@ def print_subtitle(subtitle: str) -> None: subtitle: str text to print. """ - print_color("subtitle", subtitle) + print_category_color("subtitle", subtitle) def print_command(command: str) -> None: @@ -94,7 +144,7 @@ def print_command(command: str) -> None: command: str text to print. """ - print_color("command", command) + print_category_color("command", command) def print_info(info: str) -> None: @@ -105,7 +155,7 @@ def print_info(info: str) -> None: info: str text to print. """ - print_color("info", info) + print_category_color("info", info) def print_warn(warn: str) -> None: @@ -116,7 +166,7 @@ def print_warn(warn: str) -> None: warn: str text to print. """ - print_color("warn", warn) + print_category_color("warn", warn) def print_result(result: str) -> None: @@ -127,7 +177,7 @@ def print_result(result: str) -> None: result: str text to print. """ - print_color("result", result) + print_category_color("result", result) def print_error(error: str) -> None: @@ -138,7 +188,7 @@ def print_error(error: str) -> None: error: str text to print. """ - print_color("error", error) + print_category_color("error", error) def print_deprecated(deprecated: str) -> None: @@ -149,4 +199,26 @@ def print_deprecated(deprecated: str) -> None: deprecated: str text to print. """ - print_color("deprecated", deprecated) + print_category_color("deprecated", deprecated) + + +def print_call(call: str) -> None: + """ Print call. + + Parameters + ---------- + call: str + text to print. + """ + print_color("blue", call) + + +def print_stdout(stdout: str) -> None: + """ Print stdout. + + Parameters + ---------- + stdout: str + text to print. + """ + print_color("salmon1", stdout, end="") diff --git a/brainprep/utils/utils.py b/brainprep/utils/utils.py index ad8ecc12..f8aea780 100644 --- a/brainprep/utils/utils.py +++ b/brainprep/utils/utils.py @@ -11,10 +11,8 @@ """ import inspect -import json import re import uuid -from collections.abc import Callable, Iterable from pathlib import Path from typing import ( Any, @@ -23,7 +21,6 @@ get_origin, ) -from .._version import __version__ from ..config import ( DEFAULT_OPTIONS, brainprep_options, @@ -33,14 +30,14 @@ File, ) from .color import ( - print_info, print_warn, ) def coerce_to_list( value: Any, - expected_type: type) -> Any: + expected_type: type, + ) -> Any: """ Coerce a value into a list when the expected type annotation indicates a list or tuple. @@ -81,7 +78,8 @@ def coerce_to_list( def coerce_to_path( value: Any, - expected_type: type) -> Any: + expected_type: type, + ) -> Any: """ Recursively convert values to `pathlib.Path` based on expected type annotations. @@ -129,7 +127,8 @@ def coerce_to_path( def parse_bids_keys( bids_path: File, full_path: bool = False, - check_run: bool = False) -> dict[str]: + check_run: bool = False, + ) -> dict[str]: """ Parse BIDS entities and modality from a filename or path with validation. @@ -150,11 +149,13 @@ def parse_bids_keys( The BIDS file to parse. full_path: bool If True, extract entities from the full input path rather than - only the filename. Default is False. + only the filename. + Default False. check_run: bool If True, checks whether the current run value appears more than once, assigns a UUID-style fallback if needed, and warns if even - that fallback is not unique. Default is False. + that fallback is not unique. + Default False. Returns ------- @@ -236,7 +237,8 @@ def parse_bids_keys( def check_run_fn( bids_path: File, entities: dict[str], - full_path: bool = False) -> bool: + full_path: bool = False, + ) -> bool: """ Scan the folder containing a BIDS file and verify that the run entity associated with the file appears exactly once among all matching files. @@ -250,7 +252,8 @@ def check_run_fn( modality. full_path : bool If True, extract entities from the full path instead of only the - filename. Default False. + filename. + Default False. Returns ------- @@ -291,7 +294,8 @@ def check_run_fn( def make_run_id( - filename: str) -> tuple[str, str]: + filename: str, + ) -> tuple[str, str]: """ Generate a deterministic identifier and a 5-digit short code from a filename. @@ -319,13 +323,13 @@ def make_run_id( def sidecar_from_file( - image_file: File) -> File: + image_file: File, + ) -> File: """ Infers the corresponding JSON sidecar file for a given NIfTI image file. This function checks that the input file has a ``.nii.gz`` extension and - attempts to locate a sidecar ``.json`` file with the same base name. If - either condition fails, it raises a ValueError. + attempts to locate a sidecar ``.json`` file with the same base name. Parameters ---------- @@ -367,6 +371,114 @@ def sidecar_from_file( return sidecar_file +def sbref_from_file( + image_file: File, + ) -> File | None: + """ + Infers the corresponding SBREFr file for a given BOLD NIfTI image file. + + This function checks that the input file has a ``.nii.gz`` extension and + attempts to locate a SBREF ``_sbref`` file with the same base name. None is + returned if no SBREF file is found. + + Parameters + ---------- + image_file : File + The NIfTI image file for which to infer the SBREF file. + + Returns + ------- + sbref_file : File | None + Path to the inferred SBREF file. + + Raises + ------ + ValueError + If the input file does not have a `.nii.gz` extension. + + Examples + -------- + >>> from pathlib import Path + >>> from brainprep.utils import sbref_from_file + >>> + >>> image_file = Path("/tmp/sub-01_bold.nii.gz") + >>> sbref_file = Path("/tmp/sub-01_sbref.nii.gz") + >>> sbref_file.touch() + >>> + >>> sbref_from_file(image_file) + PosixPath('/tmp/sub-01_sbref.nii.gz') + """ + if not str(image_file).endswith(".nii.gz"): + raise ValueError( + f"Input image file must be in NIIGZ format: {image_file}" + ) + sbref_file = Path( + str(image_file).replace( + "_bold.nii.gz", + "_sbref.nii.gz", + ) + ) + if not sbref_file.is_file(): + sbref_file = None + return sbref_file + + +def bvecbval_from_file( + image_file: File, + ) -> tuple[File, File]: + """ + Infers the corresponding .bvec and .bval files for a given DWI NIfTI image + file. + + This function checks that the input file has a ``.nii.gz`` extension and + attempts to locate gradient information ``.bvec`` and ``.bval`` files + with the same base name. None is returned if no gradient information + is found. + + Parameters + ---------- + image_file : File + The NIfTI image file for which to infer the JSON sidecar. + + Returns + ------- + bvec_file : File + Path to the inferred bvec file. + bvval_file : File + Path to the inferred bvel file. + + Raises + ------ + ValueError + If the input file does not have a `.nii.gz` extension. + + Examples + -------- + >>> from pathlib import Path + >>> from brainprep.utils import bvecbval_from_file + >>> + >>> image_file = Path("/tmp/sub-01_dwi.nii.gz") + >>> bvec_file = Path("/tmp/sub-01_dwi.bvec") + >>> bvec_file.touch() + >>> bval_file = Path("/tmp/sub-01_dwi.bval") + >>> bval_file.touch() + >>> + >>> bvecbval_from_file(image_file) + (PosixPath('/tmp/sub-01_dwi.bvec'), PosixPath('/tmp/sub-01_dwi.bval')) + """ + if not str(image_file).endswith(".nii.gz"): + raise ValueError( + f"Input image file must be in NIIGZ format: {image_file}" + ) + bvec_file = Path(str(image_file).replace(".nii.gz", ".bvec")) + if not bvec_file.is_file(): + bvec_file = None + bval_file = Path(str(image_file).replace(".nii.gz", ".bval")) + if not bval_file.is_file(): + bval_file = None + return bvec_file, bval_file + + def find_stack_level() -> int: """ Return the index of the first stack frame outside the ``brainprep`` @@ -425,7 +537,8 @@ def find_stack_level() -> int: def find_first_occurrence( input_file: Path, - target: str) -> Path: + target: str, + ) -> Path: """ Return the closest parent directory whose name matches `target`. diff --git a/brainprep/workflow/__init__.py b/brainprep/workflow/__init__.py index 59af155b..59cac362 100644 --- a/brainprep/workflow/__init__.py +++ b/brainprep/workflow/__init__.py @@ -14,6 +14,9 @@ brainprep_defacing, brainprep_group_defacing, ) +from .dmriprep import ( + brainprep_dmriprep, +) from .fmriprep import ( brainprep_fmriprep, brainprep_group_fmriprep, @@ -26,11 +29,18 @@ brainprep_group_quasiraw, brainprep_quasiraw, ) +from .reporting import ( + brainprep_group_reporting, +) from .sbm import ( brainprep_group_sbm, brainprep_longitudinal_sbm, brainprep_sbm, ) +from .sulcirec import ( + brainprep_group_sulcirec, + brainprep_sulcirec, +) from .vbm import ( brainprep_group_vbm, brainprep_longitudinal_vbm, @@ -44,12 +54,15 @@ "brainprep_group_fmriprep", "brainprep_group_quality_assurance", "brainprep_group_quasiraw", + "brainprep_group_reporting", "brainprep_group_sbm", + "brainprep_group_sulcirec", "brainprep_group_vbm", "brainprep_longitudinal_sbm", "brainprep_longitudinal_vbm", "brainprep_quality_assurance", "brainprep_quasiraw", "brainprep_sbm", + "brainprep_sulcirec", "brainprep_vbm", ] diff --git a/brainprep/workflow/defacing.py b/brainprep/workflow/defacing.py index bc3efeea..254e6abb 100644 --- a/brainprep/workflow/defacing.py +++ b/brainprep/workflow/defacing.py @@ -11,6 +11,7 @@ """ import shutil +from pathlib import Path import brainprep.interfaces as interfaces @@ -19,6 +20,7 @@ CoerceparamsHook, LogRuntimeHook, SaveRuntimeHook, + SignatureHook, step, ) from ..typing import ( @@ -27,7 +29,6 @@ ) from ..utils import ( Bunch, - parse_bids_keys, print_info, ) @@ -37,42 +38,50 @@ CoerceparamsHook(), BidsHook( process="defacing", - bids_file="t1_file", + bids_file="anatomical_file", add_subjects=True, - container="neurospin/brainprep-deface" + container="neurospin/brainprep-deface", ), LogRuntimeHook( - title="Subject Level Defacing" + title="Subject Level Defacing", + clear=True, ), SaveRuntimeHook(), + SignatureHook(), ] ) def brainprep_defacing( - t1_file: File, + anatomical_file: File, output_dir: Directory, keep_intermediate: bool = False, - **kwargs: dict) -> Bunch: + **kwargs: dict, + ) -> Bunch: """ - Defacing pre-processing workflow for anatomical T1-weighted images. + Defacing pre-processing workflow for anatomical images. Applies FSL's `fsl_deface` tool :footcite:p:`almagro2018deface` with - default settings to remove facial features (face and ears) from the input - image. This includes: + default settings to remove facial features (face and ears) from an input + T1-weighted MRI image. Apply defacing mask to T2-weighted or FLAIR MRI + images. This includes: - 1) Reorient the T1w image to standard MNI152 template space. - 2) Deface the T1w image. - 3) Generate a mosaic image of the defaced T1w image. + 1) Reorient the anatomical image to standard MNI152 template space. + 2) Compute a brain mask using a skull-stripping tool. + 3) Deface the T1w image or apply defacing to T2w and FLAIR images using + coregistration. + 4) Compute brain mask and defacing mask intersection. + 5) Generate a mosaic image of the defaced anatomical image. Parameters ---------- - t1_file : File - Path to the input T1w anatomical image file. + anatomical_file : File + Path to the input image file: T1w, T2w or FLAIR. output_dir : Directory Directory where the defaced image and related outputs will be saved (i.e., the root of your dataset). keep_intermediate : bool If True, retains intermediate results (e.g., reoriented image); useful - for debugging. Default False. + for debugging. + Default False. **kwargs : dict entities: dict Dictionary of parsed BIDS entities. @@ -82,22 +91,29 @@ def brainprep_defacing( Bunch A dictionary-like object containing: - - deface_t1_file : File - path to the defaced image. + - deface_anatomical_file : File - path to the defaced image. - mask_file : File - path to the defacing mask. - mosaic_file : File - path to defacing snapshots. - - summary_file : File - a TSV file containing voxel counts and + - maskdiff_file : File - a TSV file containing voxel counts and physical volumes (in mm³) for the brain/defacing masks and their intersection. + - correlations_file : File - a TSV file containing mean correlation + of aligned input image to the reference image. + - transform_file : File - path to the 12 dof (T1w) or 6 dof (T2w and + FLAIR coregistration) affine transformation. Raises ------ ValueError - If the T1w file do not follow BIDS convention. + If the input anatomical file do not follow BIDS convention. + If the input modality is not supported. + If a T1w image in the same session has not already been faced for + T2w or FLAIR processings. Notes ----- - This workflow assumes the input image is a valid T1-weighted anatomical - scan. + This workflow assumes a T1w image in the same session has already + been defaced for T2w or FLAIR processings. References ---------- @@ -107,13 +123,11 @@ def brainprep_defacing( Examples -------- >>> from brainprep.config import Config - >>> from brainprep.reporting import RSTReport >>> from brainprep.workflow import brainprep_defacing >>> >>> with Config(dryrun=True, verbose=False): - ... report = RSTReport() ... outputs = brainprep_defacing( - ... t1_file=( + ... anatomical_file=( ... "/tmp/dataset/rawdata/sub-01/ses-01/anat/" ... "sub-01_ses-01_run-01_T1w.nii.gz" ... ), @@ -121,49 +135,151 @@ def brainprep_defacing( ... ) >>> outputs Bunch( - deface_t1_file: PosixPath('...') + deface_anatomical_file: PosixPath('...') mask_file: PosixPath('...') mosaic_file: PosixPath('...') - summary_file: PosixPath('...') + maskdiff_file: PosixPath('...') + correlation_file: PosixPath('...') + transform_file: PosixPath('...') ) """ - workspace_dir = output_dir / "workspace" - workspace_dir.mkdir(parents=True, exist_ok=True) - print_info(f"setting workspace directory: {workspace_dir}") - entities = kwargs.get("entities", {}) if len(entities) == 0: raise ValueError( - f"The T1w file '{t1_file}' is not BIDS-compliant." + f"Input file not BIDS-compliant: {anatomical_file}" + ) + modality = entities["mod"] + if modality not in ("T1w", "T2w", "FLAIR"): + raise ValueError( + f"Modality not supported: {entities['mod']}" ) - reoriented_t1_file = interfaces.reorient( - t1_file, - workspace_dir, + resource_dir = Path(interfaces.__file__).parent.parent / "resources" + template_file = resource_dir / f"MNI152_T1_1mm_brain.nii.gz" + + workspace_dir = output_dir / f"workspace_{entities['run']}" + workspace_dir.mkdir(parents=True, exist_ok=True) + print_info(f"setting workspace directory: {workspace_dir}") + + reoriented_anatomical_file = interfaces.reorient( + anatomical_file, + workspace_dir / "01-reorient", entities, ) - brainmask_file = interfaces.brainmask( - reoriented_t1_file, - workspace_dir, + _, brainmask_file = interfaces.brainmask( + reoriented_anatomical_file, + workspace_dir / "02-brainmask", entities, ) - deface_t1_file, mask_file = interfaces.deface( - reoriented_t1_file, + if modality == "T1w": + deface_anatomical_file, mask_file, transform_file = interfaces.deface( + reoriented_anatomical_file, + workspace_dir / "03-deface", + entities, + ) + aligned_anatomical_file = interfaces.applyaffine( + reoriented_anatomical_file, + template_file, + transform_file, + workspace_dir / "03-deface", + entities, + interpolation="spline", + ) + else: + t1_file = list( + output_dir.glob( + f"sub-{entities['sub']}_ses-{entities['ses']}_run-*_T1w.nii.gz" + ) + ) + mask_t1_file = list( + output_dir.glob( + f"sub-{entities['sub']}_ses-{entities['ses']}_run-*_mod-T1w_" + "defacemask.nii.gz" + ) + ) + if len(t1_file) != 1 or len(mask_t1_file) != 1: + raise ValueError( + f"No T1w defaced image found: {t1_file}, {mask_t1_file}" + ) + t1_file, mask_t1_file = t1_file[0], mask_t1_file[0] + print_info(f"using T1w: {t1_file}") + print_info(f"using defacing mask: {mask_t1_file}") + aligned_anatomical_file = reoriented_anatomical_file + template_file, transform_file = interfaces.align( + t1_file, + reoriented_anatomical_file, + workspace_dir / "03-deface", + entities, + rigid=True, + quick=True, + ) + mask_file = interfaces.applyaffine( + mask_t1_file, + reoriented_anatomical_file, + transform_file, + workspace_dir / "03-deface", + entities, + interpolation="nearestneighbour", + ) + deface_anatomical_file = interfaces.applymask( + reoriented_anatomical_file, + mask_file, + workspace_dir / "03-deface", + entities, + ) + maskdiff_file = interfaces.maskdiff( + brainmask_file, + mask_file, output_dir, entities, + inv_mask2=True, + ) + correlation_file = interfaces.meancorr( + aligned_anatomical_file, + template_file, + output_dir, + correlation_threshold=None, + suffix=f"_{modality}", ) mosaic_file = interfaces.plot_defacing_mosaic( mask_file, - t1_file, + anatomical_file, output_dir, entities, ) - summary_file = interfaces.maskdiff( - brainmask_file, - mask_file, + + basename = "sub-{sub}_ses-{ses}_run-{run}".format(**entities) + out_deface_anatomical_file = output_dir / f"{basename}_{modality}.nii.gz" + out_mask_file = output_dir / f"{basename}_mod-{modality}_defacemask.nii.gz" + out_summary_file = output_dir / f"{basename}_mod-{modality}_maskinter.tsv" + out_transform_file = output_dir / f"{basename}_mod-{modality}_affine.txt" + out_correlation_file = ( + correlation_file.parent / f"{basename}_mod-{modality}_corr.tsv" + ) + interfaces.copyfiles( + [ + deface_anatomical_file, + mask_file, + transform_file, + correlation_file, + ], + [ + out_deface_anatomical_file, + out_mask_file, + out_transform_file, + out_correlation_file, + ], output_dir, - entities, - inv_mask2=True, + ) + interfaces.copyfiles( + [ + correlation_file, + ], + [ + out_correlation_file, + ], + output_dir, + move_files=True, ) if not keep_intermediate: @@ -171,10 +287,12 @@ def brainprep_defacing( shutil.rmtree(workspace_dir) return Bunch( - deface_t1_file=deface_t1_file, - mask_file=mask_file, + deface_anatomical_file=out_deface_anatomical_file, + mask_file=out_mask_file, mosaic_file=mosaic_file, - summary_file=summary_file, + maskdiff_file=maskdiff_file, + correlation_file=out_correlation_file, + transform_file=out_transform_file, ) @@ -183,35 +301,50 @@ def brainprep_defacing( CoerceparamsHook(), BidsHook( process="defacing", - container="neurospin/brainprep-deface" + container="neurospin/brainprep-deface", ), LogRuntimeHook( - title="Group Level Defacing" + title="Group Level Defacing", + clear=True, ), SaveRuntimeHook(), + SignatureHook(), ] ) def brainprep_group_defacing( + modality: str, output_dir: Directory, overlap_threshold: float = 0.05, - keep_intermediate: bool = False) -> Bunch: + correlation_threshold: float = 0.5, + keep_intermediate: bool = False, + ) -> Bunch: """ - Group level defacing pre-processing. + Group-level defacing pre-processing. - Applies the following quality control procedure: + This function applies a quality control procedure to defaced images at + the group level. It includes the following steps: 1) Generate a TSV table containing the intersection between the brain and - defacing masks. - 2) Apply threshold-based quality checks on the selected quality metrics. - 3) Generate a histogram showing the distribution of these quality metrics. + defacing masks. The optimal scenario is when there is no intersection. + 2) Generate a TSV file containing the mean correlation of each image to + the reference image (MNI for T1w or T1w for T2w and FLAIR). The optimal + scenario is when the correlation is maximized. + 3) Apply threshold-based quality checks on the selected quality metrics. + 4) Generate a histogram showing the distribution of these quality metrics. Parameters ---------- + modality : str + Modality: T1w, T2w or FLAIR. output_dir : Directory - Directory where the quality assurance related outputs will be saved + Directory where the defacing related outputs will be saved (i.e., the root of your dataset). overlap_threshold : float - Quality control threshold on the overalp score. Default 0.05. + Quality control threshold on the overalp score. + Default 0.05. + correlation_threshold : float + Quality control threshold on the correlation score. + Default 0.5. keep_intermediate : bool If True, retains intermediate results (no effect on this workflow). Default False. @@ -221,57 +354,99 @@ def brainprep_group_defacing( Bunch A dictionary-like object containing: + - correlations_file : File - a TSV file containing mean correlation + of each input image to the reference image. + - correlation_histogram_file : File - a PNG file containing the + histogram of the computed mean correlations. - overalp_file : File - a TSV file containing brain/defacing masks - intersection quality check (QC) data. + intersections. - overalp_histogram_file : File - PNG file containing the histogram of the computed overlaps. + Raises + ------ + ValueError + If the input modality is not supported. + Notes ----- This workflow assumes the subject-level analyses have already been performed. - A ``qc`` column is added to the TSV QC output table. It contains a binary flag indicating whether the produced results should be kept: ``qc = 1`` if the result passes the thresholds, otherwise ``qc = 0``. - The associated PNG histograms help verify that the chosen thresholds are neither too restrictive nor too permissive. Examples -------- >>> from brainprep.config import Config - >>> from brainprep.reporting import RSTReport >>> from brainprep.workflow import brainprep_group_defacing >>> >>> with Config(dryrun=True, verbose=False): - ... report = RSTReport() ... outputs = brainprep_group_defacing( + ... modality="T1w", ... output_dir="/tmp/dataset/derivatives", ... ) >>> outputs Bunch( + correlations_file: PosixPath('...') + correlation_histogram_file: PosixPath('...') overlap_file: PosixPath('...') overalp_histogram_file: PosixPath('...') ) """ - overlap_file = interfaces.mask_overlap( + if modality not in ("T1w", "T2w", "FLAIR"): + raise ValueError( + f"Modality not supported: {modality}" + ) + + correlations_file = interfaces.meancorr( + ( + output_dir / + "subjects" / + "sub-*" / + "ses-*" / + "quality_check" / + f"*mod-{modality}_corr.tsv" + ), + None, + output_dir, + correlation_threshold, + suffix=f"_{modality}", + ) + correlation_histogram_file = interfaces.plot_histogram( + correlations_file, + "mean_correlation", + output_dir, + bar_coords=[correlation_threshold], + suffix=f"_{modality}", + ) + + overlap_file = interfaces.maskoverlap( ( output_dir / - "subjects" / "sub-*" / "ses-*" / - "*mod-T1w_defacemask.tsv" + "subjects" / + "sub-*" / + "ses-*" / + "quality_check" / + f"*mod-{modality}_maskdiff.tsv" ), output_dir, overlap_threshold, + suffix=f"_{modality}", ) overalp_histogram_file = interfaces.plot_histogram( overlap_file, "overlap", output_dir, bar_coords=[overlap_threshold], + suffix=f"_{modality}", ) return Bunch( + correlations_file=correlations_file, + correlation_histogram_file=correlation_histogram_file, overlap_file=overlap_file, overalp_histogram_file=overalp_histogram_file, ) diff --git a/brainprep/workflow/dmriprep.py b/brainprep/workflow/dmriprep.py new file mode 100755 index 00000000..84d63197 --- /dev/null +++ b/brainprep/workflow/dmriprep.py @@ -0,0 +1,223 @@ +########################################################################## +# NSAp - Copyright (C) CEA, 2021 - 2026 +# Distributed under the terms of the CeCILL-B license, as published by +# the CEA-CNRS-INRIA. Refer to the LICENSE file or to +# http://www.cecill.info/licences/Licence_CeCILL-B_V1-en.html +# for details. +########################################################################## + +""" +Diffusion MRI pre-processing workflow. +""" + +import shutil + +import brainprep.interfaces as interfaces + +from ..decorators import ( + BidsHook, + CoerceparamsHook, + LogRuntimeHook, + SaveRuntimeHook, + SignatureHook, + step, +) +from ..typing import ( + Directory, + File, +) +from ..utils import ( + Bunch, + print_info, +) + + +@step( + hooks=[ + CoerceparamsHook(), + BidsHook( + process="dmriprep", + bids_file="t1_file", + add_subjects=True, + container="neurospin/brainprep-dmriprep", + ), + LogRuntimeHook( + title="Subject Level dMRI PreProcessing", + clear=True, + ), + SaveRuntimeHook(), + SignatureHook(), + ] +) +def brainprep_dmriprep( + t1_file: File, + dwi_files: list[File], + output_dir: Directory, + keep_intermediate: bool = False, + **kwargs: dict, + ) -> Bunch: + """ + Subject level diffusion MRI pre-processing. + + Applies the following steps: + + 1) Preprocessed diffusion weighted image (DWI) using MrTrix3 + `mrtrix3_connectome` pipeline, `preproc` and `participants` steps. + 2) Compute diffusion tensor imaging (DTI) metrics using FSL `dtifit` + 3) Compute diffusion NODDI microstructural maps using AMICO (performed + with default parameters). + + The ROI-based structural connectivity is derived using the Brainnetome + 246 (BNA246) ROI atlas. This atlas is composed of 210 cortical and 36 + subcortical ROIs defined using structural connectivity, functional + connectivity, and cytoarchitectonics. + + Parameters + ---------- + t1_file : File + Path to the input T1w image file. + dwi_files : list[File] + Path to the input diffusion weighted image files of one subject. Must + be either two reverse phase encoding DWI images or a single DWI image. + output_dir : Directory + Directory where the prep-processing related outputs will be saved + (i.e., the root of your dataset). + keep_intermediate : bool + If True, retains intermediate results (i.e., the workspace); useful + for debugging. + Default False. + **kwargs : dict + entities: dict + Dictionary of parsed BIDS entities. + + Returns + ------- + Bunch + A dictionary-like object containing: + + - dwi_preproc_file: File - pre-processed diffusion weighted image. + - tractogram_file: File - the tractogram data. + - mask_file: File - brain image file. + - connectome_file: File - structural connectome data generated using + the 'craddock200' atlas. + - fa_file: File - DTI Fractional Anisotropy map. + - md_file: File - DTI Mean Diffusivity map. + - ndi_file: File - NODDI Neurite Density Index map. + - fwf_file: File - NODDI Free Water Fraction map. + - odi_file: File - NODDI Orientation Dispersion Index map. + - geolab_labels_files: list[File] - text file containing geolab atlas + tractogram labels. + - tractseg_tractometry_files: list[File] - TractSeg tabular CSV + tractometry data. + + Raises + ------ + ValueError + If the input T1w file is not BIDS-compliant. + If the input DWI files has length different than one or two. + + Notes + ----- + This workflow assumes the T1w image is organized in BIDS. + + References + ---------- + + .. footbibliography:: + + Examples + -------- + TODO + """ + workspace_dir = output_dir / "workspace" + workspace_dir.mkdir(parents=True, exist_ok=True) + print_info(f"setting workspace directory: {workspace_dir}") + + entities = kwargs.get("entities", {}) + if len(entities) == 0: + raise ValueError( + f"The T1w file '{t1_file}' is not BIDS-compliant." + ) + + if len(dwi_files) not in (1, 2): + raise ValueError( + "DWI files must be either two reverse phase encoding DWI images " + "or a single DWI image." + ) + + if len(dwi_files) == 1: + _, mask_file = interfaces.brainmask( + t1_file, + workspace_dir / "01-brainmask", + entities, + legacy=True, + ) + b0_file = interfaces.synthb0( + t1_file, + dwi_files[0], + workspace_dir / "02-synthb0", + output_dir, + entities, + mask_file, + ) + + (dwi_preproc_file, wm_fod_file, tractogram_file, mask_file, + connectome_file, affine_file, _warp_file, + invwarp_file) = interfaces.dwiprep( + t1_file, + dwi_files, + workspace_dir, + output_dir, + entities, + ) + + fa_file, md_file = interfaces.dtifit( + dwi_preproc_file, + mask_file, + workspace_dir, + output_dir, + entities, + ) + _config_file, ndi_file, fwf_file, odi_file = interfaces.noddifit( + dwi_preproc_file, + mask_file, + workspace_dir, + output_dir, + entities, + ) + + mrtrix_warp_file, labels_files, _qc_files = interfaces.geolab_parcellation( + tractogram_file, + affine_file, + invwarp_file, + workspace_dir, + output_dir, + entities, + ) + tractometry_files = interfaces.tractseg_parcellation( + wm_fod_file, + mrtrix_warp_file, + [fa_file, md_file, ndi_file, fwf_file, odi_file], + ["FA", "MD", "NDI", "FWF", "ODI"], + workspace_dir, + output_dir, + entities, + ) + + if not keep_intermediate: + print_info(f"cleaning workspace directory: {workspace_dir}") + shutil.rmtree(workspace_dir) + + return Bunch( + dwi_preproc_file=dwi_preproc_file, + tractogram_file=tractogram_file, + mask_file=mask_file, + connectome_file=connectome_file, + fa_file=fa_file, + md_file=md_file, + ndi_file=ndi_file, + fwf_file=fwf_file, + odi_file=odi_file, + geolab_labels_files=labels_files, + tractseg_tractometry_files=tractometry_files, + ) diff --git a/brainprep/workflow/fmriprep.py b/brainprep/workflow/fmriprep.py index e1110a7c..5068c00f 100755 --- a/brainprep/workflow/fmriprep.py +++ b/brainprep/workflow/fmriprep.py @@ -20,6 +20,7 @@ CoerceparamsHook, LogRuntimeHook, SaveRuntimeHook, + SignatureHook, step, ) from ..typing import ( @@ -40,12 +41,14 @@ process="fmriprep", bids_file="t1_file", add_subjects=True, - container="neurospin/brainprep-fmriprep" + container="neurospin/brainprep-fmriprep", ), LogRuntimeHook( - title="Subject Level fMRI PreProcessing" + title="Subject Level fMRI PreProcessing", + clear=True, ), SaveRuntimeHook(), + SignatureHook(), ] ) def brainprep_fmriprep( @@ -54,7 +57,8 @@ def brainprep_fmriprep( freesurfer_dir: Directory, output_dir: Directory, keep_intermediate: bool = False, - **kwargs: dict) -> Bunch: + **kwargs: dict, + ) -> Bunch: """ Subject level functional MRI pre-processing. @@ -112,7 +116,8 @@ def brainprep_fmriprep( (i.e., the root of your dataset). keep_intermediate : bool If True, retains intermediate results (i.e., the workspace); useful - for debugging. Default False. + for debugging. + Default False. **kwargs : dict entities: dict Dictionary of parsed BIDS entities. @@ -185,7 +190,8 @@ def brainprep_fmriprep( ) if not dataset_description_file.is_file(): raise ValueError( - "A description file must be included in rawdata directory." + "A description file must be included in rawdata directory: " + f"{dataset_description_file}" ) entities = kwargs.get("entities", {}) @@ -194,7 +200,7 @@ def brainprep_fmriprep( f"The T1w file '{t1_file}' is not BIDS-compliant." ) - rfmri_outputs, qc_file = interfaces.fmriprep_wf( + rfmri_outputs, qc_file = interfaces.fmriprep_workflow( t1_file, func_files, dataset_description_file, @@ -222,7 +228,7 @@ def brainprep_fmriprep( if "run" not in entities: entities["run"] = entities_["run"] connectivity_files.append( - interfaces.func_vol_connectivity( + interfaces.fmri_connectivity( fmri_image_file, mask_file, confounds_file, @@ -259,12 +265,14 @@ def brainprep_fmriprep( CoerceparamsHook(), BidsHook( process="fmriprep", - container="neurospin/brainprep-fmriprep" + container="neurospin/brainprep-fmriprep", ), LogRuntimeHook( - title="Group Level fMRI PreProcessing" + title="Group Level fMRI PreProcessing", + clear=True, ), SaveRuntimeHook(), + SignatureHook(), ] ) def brainprep_group_fmriprep( @@ -272,7 +280,8 @@ def brainprep_group_fmriprep( fd_mean_threshold: float = 0.2, dvars_std_threshold: float = 1.5, entropy_threshold: float = 12, - keep_intermediate: bool = False) -> Bunch: + keep_intermediate: bool = False, + ) -> Bunch: """ Group level functional MRI pre-processing. @@ -304,7 +313,8 @@ def brainprep_group_fmriprep( Default 12. keep_intermediate : bool If True, retains intermediate results (i.e., the workspace); useful - for debugging. Default False. + for debugging. + Default False. Returns ------- diff --git a/brainprep/workflow/quality_assurance.py b/brainprep/workflow/quality_assurance.py index 9c850a61..8f87948f 100755 --- a/brainprep/workflow/quality_assurance.py +++ b/brainprep/workflow/quality_assurance.py @@ -19,6 +19,7 @@ CoerceparamsHook, LogRuntimeHook, SaveRuntimeHook, + SignatureHook, step, ) from ..typing import ( @@ -37,19 +38,22 @@ BidsHook( process="quality_assurance", bids_file="image_files", - container="neurospin/brainprep-quality_assurance" + container="neurospin/brainprep-quality_assurance", ), LogRuntimeHook( - title="Subject Level Quality Assurance" + title="Subject Level Quality Assurance", + clear=True, ), SaveRuntimeHook(), + SignatureHook(), ] ) def brainprep_quality_assurance( image_files: list[File], output_dir: Directory, keep_intermediate: bool = False, - **kwargs: dict) -> Bunch: + **kwargs: dict, + ) -> Bunch: """ Subject level quality assurance pre-processing workflow for MRI images. @@ -65,9 +69,10 @@ def brainprep_quality_assurance( (i.e., the root of your dataset). keep_intermediate : bool If True, retains intermediate results (i.e., the workspace); useful - for debugging. Default False. + for debugging. + Default False. **kwargs : dict - entities: lsit[dict] + entities: list[dict] Dictionaries of parsed BIDS entities. @@ -134,18 +139,21 @@ def brainprep_quality_assurance( CoerceparamsHook(), BidsHook( process="quality_assurance", - container="neurospin/brainprep-quality_assurance" + container="neurospin/brainprep-quality_assurance", ), LogRuntimeHook( - title="Group Level Quality Assurance" + title="Group Level Quality Assurance", + clear=True, ), SaveRuntimeHook(), + SignatureHook(), ] ) def brainprep_group_quality_assurance( modalities: list[str], output_dir: Directory, - keep_intermediate: bool = False) -> Bunch: + keep_intermediate: bool = False, + ) -> Bunch: """ Group-level quality assurance pre-processing. @@ -153,14 +161,14 @@ def brainprep_group_quality_assurance( applies the following quality control: 1) Generate TSV tables of Image Quality Metrics (IQMs) generated by MRIQC. - 2) Generate TSV tables of quality assurance metrics for different - modalities. - 3) Apply threshold-based quality checks on the selected quality metrics. - 4) Generate a histogram showing the distribution of these quality metrics. - - The following quality metrics are considered: - - - TODO + 2) Generate TSV tables of filtered IQMs. + 3) TODO: Apply threshold-based quality checks on the selected quality + metrics. + 4) TODO: Generate a histogram showing the distribution of these quality + metrics. + + For a detailed procedure on how we filter IQMs refer :ref:`this tutorial + ` Parameters ---------- diff --git a/brainprep/workflow/quasiraw.py b/brainprep/workflow/quasiraw.py index 88530a97..a42b1866 100644 --- a/brainprep/workflow/quasiraw.py +++ b/brainprep/workflow/quasiraw.py @@ -1,5 +1,5 @@ ########################################################################## -# NSAp - Copyright (C) CEA, 2021 - 2025 +# NSAp - Copyright (C) CEA, 2021 - 2026 # Distributed under the terms of the CeCILL-B license, as published by # the CEA-CNRS-INRIA. Refer to the LICENSE file or to # http://www.cecill.info/licences/Licence_CeCILL-B_V1-en.html @@ -20,6 +20,7 @@ CoerceparamsHook, LogRuntimeHook, SaveRuntimeHook, + SignatureHook, step, ) from ..typing import ( @@ -28,7 +29,6 @@ ) from ..utils import ( Bunch, - parse_bids_keys, print_info, ) @@ -40,46 +40,49 @@ process="quasiraw", bids_file="anatomical_file", add_subjects=True, - container="neurospin/brainprep-quasiraw" + container="neurospin/brainprep-quasiraw", ), LogRuntimeHook( - title="Subject Level Quasi-RAW" + title="Subject Level Quasi-RAW", + clear=True, ), SaveRuntimeHook(), + SignatureHook(), ] ) def brainprep_quasiraw( anatomical_file: File, output_dir: Directory, keep_intermediate: bool = False, - **kwargs: dict) -> Bunch: + **kwargs: dict, + ) -> Bunch: """ Quasi-RAW pre-processing. Applies the Quasi-RAW pre-processing described in - :footcite:p:`dufumier2022openbhb`. This includes: + :footcite:p:`dufumier2022openbhb` to T1-weighted, T2-weighted and FLAIR + MRI images. This includes: 1) Reorient the anatomical image to standard MNI152 template space. 2) Compute a brain mask using a skull-stripping tool. - 3) Apply the brain mask to the anatomical image. + 3) Perform N4 bias field correction. 4) Resample the anatomical image to 1mm isotropic voxel size. - 5) Resample the brain mask image to 1mm isotropic voxel size. - 6) Perform N4 bias field correction. - 7) Linearly (9 dof) register the image to the MNI152 1mm template space. - 8) Apply the registration to the antomical image. - 9) Apply the registration to the brain mask image. - 10) Apply the brain mask to the registered anatomical image. + 5) Linearly register the image to the MNI152 1mm template space (9 + DOF). + 6) Apply the registration to the bias field corrected antomical image. + 7) Apply the registration to the brain mask image. Parameters ---------- - anatomical_file: File - Path to the input image file. - output_dir: Directory + anatomical_file : File + Path to the input image file: T1w, T2w or FLAIR. + output_dir : Directory Directory where the outputs will be saved (i.e., the root of your dataset). keep_intermediate : bool If True, retains intermediate results (i.e., the workspace); useful - for debugging. Default False. + for debugging. + Default False. **kwargs : dict entities: dict Dictionary of parsed BIDS entities. @@ -89,9 +92,9 @@ def brainprep_quasiraw( Bunch A dictionary-like object containing: - - aligned_anatomical_file : File - path to the aligned anatomical + - aligned_anatomical_file : File - path to the aligned 1 mm anatomical image - a Nifti file with the suffix "_T1w". - - aligned_mask_file : File - path to the aligned mask image - a + - aligned_mask_file : File - path to the aligned 1 mm mask image - a Nifti file with the suffix "_mod-T1w_brainmask". - transform_file : File - path to the 9 dof affine transformation - a text file with the suffix "_mod-T1w_affine". @@ -99,11 +102,20 @@ def brainprep_quasiraw( Raises ------ ValueError - If the input anatomical file is not BIDS-compliant. + If the input anatomical file is not BIDS-compliant or if the input + modality is not supported. Notes ----- - This workflow assumes the anatomical image is organized in BIDS. + This workflow assumes the anatomical image is organized in BIDS and applies + the following optimizations: + + - **Use a coarser resolution**: Increase the shrink factor from `1` to `4` + to downsample the image before estimating the bias field, employ the + MNI152 2mm template as the reference image and scale data to a 2mm + space. + - **Use a Coarser Search Space**: Restricted rotation search range to + +/-30° on all three axes for the registration. References ---------- @@ -132,12 +144,8 @@ def brainprep_quasiraw( transform_file: PosixPath('...') ) """ - resource_dir = Path(interfaces.__file__).parent.parent / "resources" - template_file = resource_dir / "MNI152_T1_1mm_brain.nii.gz" - print_info(f"setting template file: {template_file}") - workspace_dir = output_dir / "workspace" - workspace_dir.mkdir(parents=True, exist_ok=True) - print_info(f"setting workspace directory: {workspace_dir}") + rigid = False + quick = True entities = kwargs.get("entities", {}) if len(entities) == 0: @@ -145,51 +153,63 @@ def brainprep_quasiraw( f"The anatomical file '{anatomical_file}' is not BIDS-compliant." ) + resource_dir = Path(interfaces.__file__).parent.parent / "resources" + modality = entities["mod"] + if modality not in ("T1w", "T2w", "FLAIR"): + raise ValueError( + f"Modality not supported: {entities['mod']}" + ) + modality = "T2" if modality == "FLAIR" else modality[:-1] + template_file = resource_dir / f"MNI152_{modality}_1mm_brain.nii.gz" + lowres_template_file = resource_dir / f"MNI152_{modality}_2mm_brain.nii.gz" + print_info(f"setting template file: {template_file}") + workspace_dir = output_dir / f"workspace_{entities['run']}" + workspace_dir.mkdir(parents=True, exist_ok=True) + print_info(f"setting workspace directory: {workspace_dir}") + reoriented_anatomical_file = interfaces.reorient( anatomical_file, workspace_dir / "01-reorient", entities, ) - mask_file = interfaces.brainmask( + _, mask_file = interfaces.brainmask( reoriented_anatomical_file, workspace_dir / "02-brainmask", entities, ) - masked_anatomical_file = interfaces.applymask( + bc_anatomical_file, _ = interfaces.biasfield( reoriented_anatomical_file, mask_file, - workspace_dir / "03-applymask", - entities, - ) - scaled_anatomical_file, _ = interfaces.scale( - masked_anatomical_file, - 1, - workspace_dir / "04-scale", + workspace_dir / "03-biasfield", entities, + quick=quick, ) - scaled_mask_file, _ = interfaces.scale( + bc_brain_file = interfaces.applymask( + bc_anatomical_file, mask_file, - 1, - workspace_dir / "05-scale", + workspace_dir / "03-biasfield", entities, ) - bc_anatomical_file, _ = interfaces.biasfield( - scaled_anatomical_file, - scaled_mask_file, - workspace_dir / "06-biasfield", + scaled_anatomical_file, _ = interfaces.scale( + bc_brain_file, + 2 if quick else 1, + workspace_dir / "04-scale", entities, + interpolation="trilinear" if quick else "spline", ) - _, affine_transform_file = interfaces.affine( - bc_anatomical_file, - template_file, - workspace_dir / "07-affine", + _, affine_transform_file = interfaces.align( + scaled_anatomical_file, + lowres_template_file if quick else template_file, + workspace_dir / "05-affine", entities, + rigid=rigid, + quick=quick, ) aligned_anatomical_file = interfaces.applyaffine( bc_anatomical_file, template_file, affine_transform_file, - workspace_dir / "08-applyaffine", + workspace_dir / "06-applyaffine", entities, interpolation="spline", ) @@ -197,16 +217,10 @@ def brainprep_quasiraw( mask_file, template_file, affine_transform_file, - workspace_dir / "09-applyaffine", + workspace_dir / "07-applyaffine", entities, interpolation="nearestneighbour", ) - aligned_anatomical_file = interfaces.applymask( - aligned_anatomical_file, - aligned_mask_file, - workspace_dir / "10-applymask", - entities, - ) mod = entities["mod"] basename = "sub-{sub}_ses-{ses}_run-{run}".format(**entities) @@ -214,8 +228,16 @@ def brainprep_quasiraw( output_mask_file = output_dir / f"{basename}_mod-{mod}_brainmask.nii.gz" output_transform_file = output_dir / f"{basename}_mod-{mod}_affine.txt" interfaces.copyfiles( - [aligned_anatomical_file, aligned_mask_file, affine_transform_file], - [output_anatomical_file, output_mask_file, output_transform_file], + [ + aligned_anatomical_file, + aligned_mask_file, + affine_transform_file, + ], + [ + output_anatomical_file, + output_mask_file, + output_transform_file, + ], output_dir, ) @@ -235,18 +257,22 @@ def brainprep_quasiraw( CoerceparamsHook(), BidsHook( process="quasiraw", - container="neurospin/brainprep-quasiraw" + container="neurospin/brainprep-quasiraw", ), LogRuntimeHook( - title="Group Level Quasi-RAW" + title="Group Level Quasi-RAW", + clear=True, ), SaveRuntimeHook(), + SignatureHook(), ] ) def brainprep_group_quasiraw( + modality: str, output_dir: Directory, correlation_threshold: float = 0.5, - keep_intermediate: bool = False) -> Bunch: + keep_intermediate: bool = False, + ) -> Bunch: """ Group level Quasi-RAW pre-processing. @@ -254,22 +280,26 @@ def brainprep_group_quasiraw( This includes: 1) Generate a TSV file containing the mean correlation of each image to - the template. + the template. The optimal scenario is when the correlation is maximized. 2) Apply threshold-based quality checks on the selected quality metrics. 3) Generate a histogram showing the distribution of these quality metrics. - 4) Computing a PCA embedding of the images. - 5) Generating a scatter plot of the first two PCA components with BIDS + 4) Compute a PCA embedding of the images. + 5) Generate a scatter plot of the first two PCA components with BIDS annotations for visual inspection. Parameters ---------- + modality : str + Modality: T1w, T2w or FLAIR. output_dir : Directory Working directory containing all the subjects. correlation_threshold : float - Quality control threshold on the correlation score. Default 0.5. + Quality control threshold on the correlation score. + Default 0.5. keep_intermediate : bool If True, retains intermediate results (i.e., the workspace); useful - for debugging. Default False. + for debugging. + Default False. Returns ------- @@ -277,37 +307,40 @@ def brainprep_group_quasiraw( A dictionary-like object containing: - correlations_file : File - a TSV file containing mean correlation - of each input image to the atlas image quality check (QC) data. - - correlation_histogram_file : File - PNG file containing the + of each input image to the atlas image. + - correlation_histogram_file : File - a PNG file containing the histogram of the computed mean correlations. - pca_file : File - a TSV file containing PCA two first components as two columns named ``pc1`` and ``pc2``, as well as BIDS ``participant_id``, ``session``, and ``run``. - - pca_image_file : File - PNG file containing the two first PCA + - pca_image_file : File - a PNG file containing the two first PCA components with ``participant_id``, ``session``, and ``run`` annotations. + Raises + ------ + ValueError + If the input modality is not supported. + Notes ----- This workflow assumes the subject-level analyses have already been performed. - - A ``qc`` column is added to the TSV QC output table. It contains a - binary flag indicating whether the produced results should be kept: - ``qc = 1`` if the result passes the thresholds, otherwise ``qc = 0``. - + A ``qc`` column is added to the ``correlations_file`` output table. + It contains a binary flag indicating whether the produced results should + be kept: ``qc = 1`` if the result passes the thresholds, otherwise + ``qc = 0``. The associated PNG histograms help verify that the chosen thresholds are neither too restrictive nor too permissive. Examples -------- >>> from brainprep.config import Config - >>> from brainprep.reporting import RSTReport >>> from brainprep.workflow import brainprep_group_quasiraw >>> >>> with Config(dryrun=True, verbose=False): - ... report = RSTReport() ... outputs = brainprep_group_quasiraw( + ... modality="T1w", ... output_dir="/tmp/dataset/derivatives", ... ) >>> outputs @@ -319,30 +352,39 @@ def brainprep_group_quasiraw( ) """ resource_dir = Path(interfaces.__file__).parent.parent / "resources" - template_file = resource_dir / "MNI152_T1_1mm_brain.nii.gz" + if modality not in ("T1w", "T2w", "FLAIR"): + raise ValueError( + f"Modality not supported: {modality}" + ) + modality_ = "T2" if modality == "FLAIR" else modality[:-1] + template_file = resource_dir / f"MNI152_{modality_}_1mm_brain.nii.gz" print_info(f"setting template file: {template_file}") - correlations_file = interfaces.mean_correlation( - output_dir / "subjects" / "sub-*" / "ses-*" / "*_T1w.nii.gz", + correlations_file = interfaces.meancorr( + output_dir / "subjects" / "sub-*" / "ses-*" / f"*_{modality}.nii.gz", template_file, output_dir, correlation_threshold, + suffix=f"_{modality}", ) correlation_histogram_file = interfaces.plot_histogram( correlations_file, "mean_correlation", output_dir, bar_coords=[correlation_threshold], + suffix=f"_{modality}", ) - pca_file = interfaces.incremental_pca( - output_dir / "subjects" / "sub-*" / "ses-*" / "*_T1w.nii.gz", + pca_file = interfaces.pca( + output_dir / "subjects" / "sub-*" / "ses-*" / f"*_{modality}.nii.gz", output_dir, batch_size=50, + suffix=f"_{modality}", ) pca_image_file = interfaces.plot_pca( pca_file, output_dir, + suffix=f"_{modality}", ) return Bunch( diff --git a/brainprep/workflow/reporting.py b/brainprep/workflow/reporting.py new file mode 100644 index 00000000..92f89741 --- /dev/null +++ b/brainprep/workflow/reporting.py @@ -0,0 +1,143 @@ +########################################################################## +# NSAp - Copyright (C) CEA, 2021 - 2026 +# Distributed under the terms of the CeCILL-B license, as published by +# the CEA-CNRS-INRIA. Refer to the LICENSE file or to +# http://www.cecill.info/licences/Licence_CeCILL-B_V1-en.html +# for details. +########################################################################## + +""" +Reporting workflow. +""" + +import shutil +from datetime import datetime + +import brainprep.interfaces as interfaces + +from .._version import __version__ +from ..decorators import ( + CoerceparamsHook, + LogRuntimeHook, + SignatureHook, + step, +) +from ..reporting import generate_qc_report +from ..typing import ( + Directory, +) +from ..utils import ( + Bunch, + print_info, +) + + +@step( + hooks=[ + CoerceparamsHook(), + LogRuntimeHook( + title="Reporting", + clear=True, + ), + SignatureHook(), + ] +) +def brainprep_group_reporting( + output_dir: Directory, + keep_intermediate: bool = False, + ) -> Bunch: + """ + Pre-processings reporting. + + This function generates a quality control (QC) report for the BrainPrep + workflows. It includes the following steps: + + 1) Generate a configuration file the following workflows: + quality assurance, defacing, quasi-raw. + 2) Create a single HTML file regrouping the QC results. + + Parameters + ---------- + output_dir : Directory + Directory where the outputs will be saved (i.e., the root of your + dataset). + keep_intermediate : bool + If True, retains intermediate results (i.e., the workspace); useful + for debugging. + Default False. + + Returns + ------- + Bunch + A dictionary-like object containing: + + - html_file : File + Path to the generated HTML report. + + References + ---------- + .. footbibliography:: + + Examples + -------- + >>> from brainprep.config import Config + >>> from brainprep.workflow import brainprep_group_reporting + >>> + >>> with Config(dryrun=True, verbose=False): + ... outputs = brainprep_group_reporting( + ... output_dir="/tmp/dataset/derivatives", + ... ) + >>> outputs + Bunch( + html_file: PosixPath('...') + ) + """ + workspace_dir = output_dir / "derivatives" / "workspace" + workspace_dir.mkdir(parents=True, exist_ok=True) + print_info(f"setting workspace directory: {workspace_dir}") + + defacing_conf_file = interfaces.parse_defacing( + output_dir, + workspace_dir, + ) + + quasiraw_conf_file = interfaces.parse_quasiraw( + output_dir, + workspace_dir, + ) + + qa_conf_file = interfaces.parse_qa( + output_dir, + workspace_dir, + ) + + html_file = ( + output_dir / + "derivatives" / + "reporting.html" + ) + dryrun = not defacing_conf_file.is_file() + + if not dryrun: + report = generate_qc_report( + title="BrainPrep", + version=__version__, + date=datetime.now().strftime("%d.%m.%Y"), + data=[ + qa_conf_file, + defacing_conf_file, + quasiraw_conf_file, + ], + ) + + report.save_as_html(html_file) + + interfaces.htmlmin(html_file) + + if not keep_intermediate: + print_info(f"cleaning workspace directory: {workspace_dir}") + shutil.rmtree(workspace_dir) + + return Bunch( + html_file=html_file, + ) diff --git a/brainprep/workflow/sbm.py b/brainprep/workflow/sbm.py index 19009835..e3fc3c14 100644 --- a/brainprep/workflow/sbm.py +++ b/brainprep/workflow/sbm.py @@ -20,6 +20,7 @@ CoerceparamsHook, LogRuntimeHook, SaveRuntimeHook, + SignatureHook, step, ) from ..typing import ( @@ -29,7 +30,6 @@ from ..utils import ( Bunch, find_first_occurrence, - parse_bids_keys, print_deprecated, print_info, ) @@ -42,12 +42,14 @@ process="sbm", bids_file="t1_file", add_subjects=True, - container="neurospin/brainprep-sbm" + container="neurospin/brainprep-sbm", ), LogRuntimeHook( - title="Subject Level SBM" + title="Subject Level SBM", + clear=True, ), SaveRuntimeHook(), + SignatureHook(), ] ) def brainprep_sbm( @@ -57,7 +59,8 @@ def brainprep_sbm( do_lgi: bool = False, wm_file: File | None = None, keep_intermediate: bool = False, - **kwargs: dict) -> Bunch: + **kwargs: dict, + ) -> Bunch: """ SBM pre-processing. @@ -84,14 +87,16 @@ def brainprep_sbm( Default 'sbm'. do_lgi : bool Perform the Local Gyrification Index (LGI) computation - requires - Matlab. Default False. + Matlab. + Default False. wm_file : File | None Path to the custom white matter mask - we assume `recon-all` has been run at least upto the 'wm.mgz' file creation. It has to be in the subject's FreeSurfer space (1mm iso + aligned with brain.mgz) with values in [0, 1] (i.e. probability of being white matter). For example, it can be the 'brain_pve_2.nii.gz' white matter - probability map created by FSL `fast`. Default None. + probability map created by FSL `fast`. + Default None. .. deprecated:: 1.0.0 @@ -102,7 +107,8 @@ def brainprep_sbm( studies. This parameter has no effect! keep_intermediate : bool If True, retains intermediate results (i.e., the workspace); useful - for debugging. Default False. + for debugging. + Default False. **kwargs : dict entities: dict Dictionary of parsed BIDS entities. @@ -182,15 +188,16 @@ def brainprep_sbm( brainparc_image_file: PosixPath('...') ) """ - workspace_dir = output_dir / "workspace" - workspace_dir.mkdir(parents=True, exist_ok=True) - print_info(f"setting workspace directory: {workspace_dir}") - entities = kwargs.get("entities", {}) if len(entities) == 0: raise ValueError( f"The T1w file '{t1_file}' is not BIDS-compliant." ) + + workspace_dir = output_dir / f"workspace_{entities['run']}" + workspace_dir.mkdir(parents=True, exist_ok=True) + print_info(f"setting workspace directory: {workspace_dir}") + if wm_file is not None: print_deprecated( "You passed a white matter file as input. This behavior is " @@ -225,38 +232,42 @@ def brainprep_sbm( entities, resume=False, ) - interfaces.freesurfer_command_status( + interfaces.freesurfer_status( log_file, command="recon-all", ) if do_lgi: - _, _ = interfaces.localgi( + _, _ = interfaces.reconall_localgi( output_dir, entities, ) - interfaces.freesurfer_command_status( + interfaces.freesurfer_status( log_file, command="recon-all", ) - left_reg_file, right_reg_file = interfaces.fsaveragesym_surfreg( + left_reg_file, right_reg_file, log_file = interfaces.reconall_surfreg( output_dir, entities, ) + interfaces.freesurfer_status( + log_file, + command="xhemireg", + ) (lh_thickness_file, rh_thickness_file, lh_curv_file, rh_curv_file, lh_area_file, rh_area_file, lh_pial_lgi_file, rh_pial_lgi_file, - lh_sulc_file, rh_sulc_file) = interfaces.fsaveragesym_projection( + lh_sulc_file, rh_sulc_file) = interfaces.reconall_projection( left_reg_file, right_reg_file, output_dir, entities, ) (aparc_aseg_file, aparc_a2009s_aseg_file, aseg_file, wm_file, - rawavg_file, ribbon_file, brain_file) = interfaces.mgz_to_nii( + rawavg_file, ribbon_file, brain_file) = interfaces.convertmgz( output_dir, entities, ) (wm_mask_file, gm_mask_file, csf_mask_file, - brain_mask_file) = interfaces.freesurfer_tissues( + brain_mask_file) = interfaces.reconall_tissues( workspace_dir, output_dir, entities, @@ -271,13 +282,6 @@ def brainprep_sbm( ) subject_dir = output_dir / f"run-{entities['run']}" - mapping = { - str(find_first_occurrence(output_dir, "derivatives")): "DERIVATIVES", - } - if "rawdata" in str(t1_file): - mapping.update({ - str(find_first_occurrence(t1_file, "rawdata")): "RAWDATA", - }) for log_file in [ *list(subject_dir.glob("scripts/recon-all.*")), subject_dir / "scripts" / "seg2cc.log", @@ -286,7 +290,12 @@ def brainprep_sbm( if log_file.is_file(): interfaces.anonfile( log_file, - mapping, + find_first_occurrence(output_dir, "derivatives"), + ( + find_first_occurrence(t1_file, "rawdata") + if "rawdata" in str(t1_file) + else None + ), ) for name in ("fsaverage", "fsaverage_sym"): @@ -331,21 +340,24 @@ def brainprep_sbm( bids_file="t1_files", add_subjects=True, longitudinal=True, - container="neurospin/brainprep-sbm" + container="neurospin/brainprep-sbm", ), LogRuntimeHook( - title="Longitudinal SBM" + title="Longitudinal SBM", + clear=True, ), SaveRuntimeHook( parent=True ), + SignatureHook(), ] ) def brainprep_longitudinal_sbm( t1_files: list[File], output_dir: Directory, keep_intermediate: bool = False, - **kwargs: dict) -> Bunch: + **kwargs: dict, + ) -> Bunch: """ Longitudinal SBM preprocessing. @@ -363,7 +375,8 @@ def brainprep_longitudinal_sbm( FreeSurfer working directory containing all the subjects. keep_intermediate : bool If True, retains intermediate results (i.e., the workspace); useful - for debugging. Default False. + for debugging. + Default False. **kwargs : dict entities: list[dict] Dictionaries of parsed BIDS entities. @@ -431,7 +444,7 @@ def brainprep_longitudinal_sbm( entities, ) for log_file in [log_template_file, *log_files]: - interfaces.freesurfer_command_status( + interfaces.freesurfer_status( log_file, command="recon-all", ) @@ -457,13 +470,6 @@ def brainprep_longitudinal_sbm( content=True, ) - mapping = { - str(find_first_occurrence(output_dir, "derivatives")): "DERIVATIVES", - } - if "rawdata" in str(t1_files[0]): - mapping.update({ - str(find_first_occurrence(t1_files[0], "rawdata")): "RAWDATA", - }) for source_dir in [ output_dir.parent / "template", *subject_dirs, @@ -476,7 +482,12 @@ def brainprep_longitudinal_sbm( if log_file.is_file(): interfaces.anonfile( log_file, - mapping, + find_first_occurrence(output_dir, "derivatives"), + ( + find_first_occurrence(t1_files[0], "rawdata") + if "rawdata" in str(t1_files[0]) + else None + ), ) for target_dir in output_dir.parent.iterdir(): @@ -497,19 +508,22 @@ def brainprep_longitudinal_sbm( CoerceparamsHook(), BidsHook( process="sbm", - container="neurospin/brainprep-sbm" + container="neurospin/brainprep-sbm", ), LogRuntimeHook( - title="Group Level SBM" + title="Group Level SBM", + clear=True, ), SaveRuntimeHook(), + SignatureHook(), ] ) def brainprep_group_sbm( output_dir: Directory, euler_threshold: int = -217, longitudinal: bool = False, - keep_intermediate: bool = False) -> Bunch: + keep_intermediate: bool = False, + ) -> Bunch: """ Group level SBM pre-processing. @@ -530,12 +544,15 @@ def brainprep_group_sbm( output_dir : Directory FreeSurfer working directory containing all the subjects. euler_threshold : int - Quality control threshold on the Euler number. Default -217. + Quality control threshold on the Euler number. + Default -217. longitudinal : bool - If True, consider the longitudinal data as inputs. Default False. + If True, consider the longitudinal data as inputs. + Default False. keep_intermediate : bool If True, retains intermediate results (i.e., the workspace); useful - for debugging. Default False. + for debugging. + Default False. Returns ------- @@ -593,12 +610,12 @@ def brainprep_group_sbm( workspace_dir.mkdir(parents=True, exist_ok=True) print_info(f"setting workspace directory: {workspace_dir}") - summary_files = interfaces.freesurfer_features_summary( + summary_files = interfaces.reconall_summary( workspace_dir, output_dir, ) if not longitudinal: - euler_numbers_file = interfaces.euler_numbers( + euler_numbers_file = interfaces.eulernums( output_dir, ) euler_numbers_histogram_file = interfaces.plot_histogram( diff --git a/brainprep/workflow/sulcirec.py b/brainprep/workflow/sulcirec.py new file mode 100755 index 00000000..aa66e872 --- /dev/null +++ b/brainprep/workflow/sulcirec.py @@ -0,0 +1,230 @@ +########################################################################## +# NSAp - Copyright (C) CEA, 2021 - 2025 +# Distributed under the terms of the CeCILL-B license, as published by +# the CEA-CNRS-INRIA. Refer to the LICENSE file or to +# http://www.cecill.info/licences/Licence_CeCILL-B_V1-en.html +# for details. +########################################################################## + +""" +Functional MRI pre-processing workflow. +""" + +import shutil + +import brainprep.interfaces as interfaces + +from ..decorators import ( + BidsHook, + CoerceparamsHook, + LogRuntimeHook, + SaveRuntimeHook, + SignatureHook, + step, +) +from ..typing import ( + Directory, + File, +) +from ..utils import ( + Bunch, + print_info, +) + + +@step( + hooks=[ + CoerceparamsHook(), + BidsHook( + process="sulcirec", + bids_file="t1_file", + add_subjects=True, + container="neurospin/brainprep-sulcirec", + ), + LogRuntimeHook( + title="Subject Level Sulci Reconstruction", + clear=True, + ), + SaveRuntimeHook(), + SignatureHook(), + ] +) +def brainprep_sulcirec( + t1_file: File, + output_dir: Directory, + keep_intermediate: bool = False, + **kwargs: dict, + ) -> Bunch: + """ + Subject level sulci reconstruction. + + Applies morphologist tool :footcite:p:`fischer2012morphologist` for + cortical sulci extraction and identification. + + Parameters + ---------- + t1_file : File + Path to the input T1w image file. + output_dir : Directory + Directory where the prep-processing related outputs will be saved + (i.e., the root of your dataset). + keep_intermediate : bool + If True, retains intermediate results (i.e., the workspace); useful + for debugging. + Default False. + **kwargs : dict + entities: dict + Dictionary of parsed BIDS entities. + + Returns + ------- + Bunch + A dictionary-like object containing: + + - sulci_graphs_files : list[File] - Left and right hemispheres sulci. + - qc_file : File - QC TSV file. + + Raises + ------ + ValueError + If the input T1w file is not BIDS-compliant. + + Notes + ----- + This workflow assumes the T1w image is organized in BIDS. + + References + ---------- + + .. footbibliography:: + + Examples + -------- + >>> from brainprep.config import Config + >>> from brainprep.workflow import brainprep_sulcirec + >>> + >>> with Config(dryrun=True, verbose=False): + ... outputs = brainprep_sulcirec( + ... t1_file=( + ... "/tmp/dataset/rawdata/sub-01/ses-01/anat/" + ... "sub-01_ses-01_run-01_T1w.nii.gz" + ... ), + ... output_dir="/tmp/dataset/derivatives", + ... ) + >>> outputs + Bunch( + sulci_graphs_files: [PosixPath('...'), PosixPath('...')] + qc_file: PosixPath('...') + ) + """ + entities = kwargs.get("entities", {}) + if len(entities) == 0: + raise ValueError( + f"The T1w file '{t1_file}' is not BIDS-compliant." + ) + + workspace_dir = output_dir / f"workspace_{entities['run']}" + workspace_dir.mkdir(parents=True, exist_ok=True) + print_info(f"setting workspace directory: {workspace_dir}") + + sulci_graphs_files, qc_file = interfaces.morphologist_workflow( + t1_file, + output_dir, + workspace_dir, + entities, + ) + + if not keep_intermediate: + print_info(f"cleaning workspace directory: {workspace_dir}") + shutil.rmtree(workspace_dir) + + return Bunch( + sulci_graphs_files=sulci_graphs_files, + qc_file=qc_file, + ) + + +@step( + hooks=[ + CoerceparamsHook(), + BidsHook( + process="sulcirec", + container="neurospin/brainprep-sulcirec", + ), + LogRuntimeHook( + title="Group Level Sulci Reconstruction", + clear=True, + ), + SaveRuntimeHook(), + SignatureHook(), + ] +) +def brainprep_group_sulcirec( + output_dir: Directory, + keep_intermediate: bool = False, + ) -> Bunch: + """ + Group level sulci reconstruction pre-processing. + + Applies the following quality control procedure: + + 1) Generate a TSV table containing the intersection between the brain and + defacing masks. + 2) Apply threshold-based quality checks on the selected quality metrics. + 3) Generate a histogram showing the distribution of these quality metrics. + + Parameters + ---------- + output_dir : Directory + Directory where the quality assurance related outputs will be saved + (i.e., the root of your dataset). + keep_intermediate : bool + If True, retains intermediate results (no effect on this workflow). + Default False. + + Returns + ------- + Bunch + A dictionary-like object containing: + + - morphometry_files : list[File] - two TSV files containing ROI-based + sulcal and brain volumes morphometries. + - group_stats_file : File - a TSV file containing a binary ``qc`` + column indicating the morphologist quality control result. + + Notes + ----- + This workflow assumes the subject-level analyses have already been + performed. + + A ``qc`` column is added to the TSV QC output table. It contains a + binary flag indicating whether the produced results should be kept: + ``qc = 1`` if the result passes the thresholds, otherwise ``qc = 0``. + + Examples + -------- + >>> from brainprep.config import Config + >>> from brainprep.workflow import brainprep_group_sulcirec + >>> + >>> with Config(dryrun=True, verbose=False): + ... outputs = brainprep_group_sulcirec( + ... output_dir="/tmp/dataset/derivatives", + ... ) + >>> outputs + Bunch( + morphometry_files: [PosixPath('...'), PosixPath('...')] + group_stats_file: PosixPath('...') + ) + """ + morphometry_files = interfaces.morphologist_morphometry( + output_dir, + ) + + group_stats_file = interfaces.sulcirec_metrics( + output_dir, + ) + + return Bunch( + morphometry_files=morphometry_files, + group_stats_file=group_stats_file, + ) diff --git a/brainprep/workflow/vbm.py b/brainprep/workflow/vbm.py index 6d07c57f..b03624ae 100644 --- a/brainprep/workflow/vbm.py +++ b/brainprep/workflow/vbm.py @@ -22,6 +22,7 @@ CoerceparamsHook, LogRuntimeHook, SaveRuntimeHook, + SignatureHook, step, ) from ..typing import ( @@ -31,7 +32,6 @@ from ..utils import ( Bunch, find_first_occurrence, - parse_bids_keys, ) @@ -42,19 +42,22 @@ process="vbm", bids_file="t1_file", add_subjects=True, - container="neurospin/brainprep-vbm" + container="neurospin/brainprep-vbm", ), LogRuntimeHook( - title="Subject Level VBM" + title="Subject Level VBM", + clear=True, ), SaveRuntimeHook(), + SignatureHook(), ] ) def brainprep_vbm( t1_file: File, output_dir: Directory, keep_intermediate: bool = False, - **kwargs: dict) -> Bunch: + **kwargs: dict, + ) -> Bunch: """ Voxel-based morphometry (VBM) pre-processing. @@ -69,7 +72,8 @@ def brainprep_vbm( Path to the output directory. keep_intermediate : bool If True, retains intermediate results (i.e., the workspace); useful - for debugging. Default False. + for debugging. + Default False. **kwargs : dict entities: dict Dictionary of parsed BIDS entities. @@ -100,28 +104,26 @@ def brainprep_vbm( f"The T1w file '{t1_file}' is not BIDS-compliant." ) - batch_file = interfaces.write_catbatch( + batch_file = interfaces.writebatch( [t1_file], output_dir.parent, [entities], ) - gm_files, qc_files = interfaces.cat12vbm_wf( + gm_files, qc_files = interfaces.cat12vbm_workflow( [t1_file], batch_file, output_dir.parent, [entities], ) - mapping = { - str(find_first_occurrence(output_dir, "derivatives")): "DERIVATIVES", - } - if "rawdata" in str(t1_file): - mapping.update({ - str(find_first_occurrence(t1_file, "rawdata")): "RAWDATA", - }) interfaces.anonfile( batch_file, - mapping, + find_first_occurrence(output_dir, "derivatives"), + ( + find_first_occurrence(t1_file, "rawdata") + if "rawdata" in str(t1_file) + else None + ), ) return Bunch( @@ -139,14 +141,16 @@ def brainprep_vbm( bids_file="t1_files", add_subjects=True, longitudinal=True, - container="neurospin/brainprep-vbm" + container="neurospin/brainprep-vbm", ), LogRuntimeHook( - title="Longitudinal VBM" + title="Longitudinal VBM", + clear=True, ), SaveRuntimeHook( parent=True, ), + SignatureHook(), ] ) def brainprep_longitudinal_vbm( @@ -154,7 +158,8 @@ def brainprep_longitudinal_vbm( model: int, output_dir: Directory, keep_intermediate: bool = False, - **kwargs: dict) -> Bunch: + **kwargs: dict, + ) -> Bunch: """ Longitudinal voxel based morphometry (VBM) pre-processing. @@ -172,7 +177,8 @@ def brainprep_longitudinal_vbm( Path to the output directory. keep_intermediate : bool If True, retains intermediate results (i.e., the workspace); useful - for debugging. Default False. + for debugging. + Default False. **kwargs : dict entities: list[dict] Dictionaries of parsed BIDS entities. @@ -204,29 +210,27 @@ def brainprep_longitudinal_vbm( f"The T1w file '{path}' is not BIDS-compliant." ) - batch_file = interfaces.write_catbatch( + batch_file = interfaces.writebatch( t1_files, output_dir.parent, entities, model_long=model, ) - gm_files, qc_files = interfaces.cat12vbm_wf( + gm_files, qc_files = interfaces.cat12vbm_workflow( t1_files, batch_file, output_dir.parent, entities, ) - mapping = { - str(find_first_occurrence(output_dir, "derivatives")): "DERIVATIVES", - } - if "rawdata" in str(t1_files[0]): - mapping.update({ - str(find_first_occurrence(t1_files[0], "rawdata")): "RAWDATA", - }) interfaces.anonfile( batch_file, - mapping, + find_first_occurrence(output_dir, "derivatives"), + ( + find_first_occurrence(t1_files[0], "rawdata") + if "rawdata" in str(t1_files[0]) + else None + ), ) return Bunch( @@ -241,12 +245,14 @@ def brainprep_longitudinal_vbm( CoerceparamsHook(), BidsHook( process="vbm", - container="neurospin/brainprep-vbm" + container="neurospin/brainprep-vbm", ), LogRuntimeHook( - title="Group Leve VBM" + title="Group Level VBM", + clear=True, ), SaveRuntimeHook(), + SignatureHook(), ] ) def brainprep_group_vbm( @@ -255,7 +261,8 @@ def brainprep_group_vbm( iqr_threshold: float = 4.5, correlation_threshold: float = 0.5, longitudinal: bool = False, - keep_intermediate: bool = False) -> Bunch: + keep_intermediate: bool = False, + ) -> Bunch: """ Group-level VBM pre-processing. @@ -287,16 +294,21 @@ def brainprep_group_vbm( output_dir : Directory Working directory containing all the subjects. ncr_threshold : float - Quality control threshold on the NCR scores. Default 4.5. + Quality control threshold on the NCR scores. + Default 4.5. iqr_threshold : float - Quality control threshold on the IQR scores. Default 4.5. + Quality control threshold on the IQR scores. + Default 4.5. correlation_threshold : float - Quality control threshold on the correlation score. Default 0.5. + Quality control threshold on the correlation score. + Default 0.5. longitudinal : bool - If True, consider the longitudinal data as inputs. Default False. + If True, consider the longitudinal data as inputs. + Default False. keep_intermediate : bool If True, retains intermediate results (i.e., the workspace); useful - for debugging. Default False. + for debugging. + Default False. Returns ------- @@ -361,7 +373,7 @@ def brainprep_group_vbm( output_dir, ) - correlations_file = interfaces.mean_correlation( + correlations_file = interfaces.meancorr( output_dir / "subjects" / "sub-*" / "ses-*" / "mri" / "wm*_T1w.nii", darteltpm_file, output_dir, diff --git a/brainprep/wrappers.py b/brainprep/wrappers.py index 14396140..418f1e6b 100644 --- a/brainprep/wrappers.py +++ b/brainprep/wrappers.py @@ -21,6 +21,7 @@ from .utils import ( print_error, print_info, + print_stdout, ) @@ -57,16 +58,25 @@ def run_command( process = subprocess.Popen( cmd, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, ) - stdout, stderr = process.communicate() + + stdout_lines = [] + for line in iter(process.stdout.readline, ""): + print_stdout(line) + stdout_lines.append(line) + process.stdout.close() + stdout = "".join(stdout_lines) + + process.wait() if process.returncode != 0: - print_info(stdout.decode("utf-8")) - print_error(stderr.decode("utf-8")) + print_error(stdout) raise RuntimeError(f"Command execution failed: {' '.join(cmd)}") - return stdout.decode("utf-8") + return stdout def is_list_str( diff --git a/containers/Dockerfile.defacing b/containers/Dockerfile.defacing index 97e3a919..7b8d93bd 100644 --- a/containers/Dockerfile.defacing +++ b/containers/Dockerfile.defacing @@ -1,3 +1,5 @@ +FROM freesurfer/synthstrip:1.8 AS synthstrip + FROM ubuntu:24.04 ENV LANG="en_US.UTF-8" \ @@ -17,7 +19,8 @@ RUN sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ && chmod 777 /opt && chmod a+s /opt # Parameters -ENV FSL_VERSION="6.0.7.19" +ENV FSL_VERSION="6.0.7.19" \ + FS_SYNTHSTRIP_VERSION="1.8" # Install: FSL ENV FSLDIR="/opt/fsl" \ @@ -59,9 +62,26 @@ RUN apt-get update -qq \ RUN echo "Installing FSL ..." \ && curl -fsSL https://fsl.fmrib.ox.ac.uk/fsldownloads/fslconda/releases/fslinstaller.py | python3 - -d $FSLDIR -V $FSL_VERSION -# Install: brainprep +# Install: pixi RUN curl -fsSL https://pixi.sh/install.sh | bash ENV PATH="/root/.pixi/bin:${PATH}" + +# Install: FreeSurfer SynthStrip +ENV FREESURFER_HOME="/freesurfer" +COPY --from=synthstrip $FREESURFER_HOME $FREESURFER_HOME +WORKDIR /opt/freesurfer +RUN pixi init && \ + pixi add python=3.10 +COPY --from=synthstrip /freesurfer/env/lib/python3.10/site-packages \ + .pixi/envs/default/lib/python3.10/site-packages +COPY --from=synthstrip /freesurfer/mri_synthstrip \ + .pixi/envs/default/mri_synthstrip +RUN echo '#!/bin/bash' > /usr/local/bin/mri_synthstrip && \ + echo '/opt/freesurfer/.pixi/envs/default/bin/python /opt/freesurfer/.pixi/envs/default/mri_synthstrip "$@"' \ + >> /usr/local/bin/mri_synthstrip && \ + chmod +x /usr/local/bin/mri_synthstrip + +# Install: brainprep WORKDIR /opt/brainprep RUN apt-get update -qq \ && apt-get install -y -q --no-install-recommends \ @@ -84,4 +104,5 @@ LABEL org.opencontainers.image.title="BrainPrep Defacing" \ org.opencontainers.image.documentation="https://brainprepdesk.github.io/brainprep/stable" \ org.opencontainers.image.source="https://github.com/brainprepdesk/brainprep" \ org.opencontainers.image.created="2025-01-15" \ - com.brainprep.fsl.version="${FSL_VERSION}" + com.brainprep.fsl.version="${FSL_VERSION}" \ + com.brainprep.freesurfer-synthstrip.version="${FS_SYNTHSTRIP_VERSION}" diff --git a/containers/Dockerfile.dmriprep b/containers/Dockerfile.dmriprep new file mode 100644 index 00000000..562c80f7 --- /dev/null +++ b/containers/Dockerfile.dmriprep @@ -0,0 +1,351 @@ +# ---------- Global Build Arguments ---------- +ARG MAKE_JOBS="1" +ARG DEBIAN_FRONTEND="noninteractive" + +# ---------- Base Stage: Core Dependencies ---------- +FROM ubuntu:24.04 AS base +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + curl \ + python3 \ + tar \ + unzip \ + wget +ENV PATH="/root/.pixi/bin:${PATH}" +RUN curl -fsSL https://pixi.sh/install.sh | bash \ + && pixi --version + + +# ---------- Downloader Stages (Parallelizable) ---------- +# AAL Downloader +FROM base AS aal-downloader +WORKDIR /opt/aal +RUN wget --no-check-certificate \ + https://data.kg.ebrains.eu/zip?container=https://data-proxy.ebrains.eu/api/v1/buckets/p4791e-ext-d000035_AAL1Atlas_pub?prefix=Release2018_SPM12 \ + -O aal1_for_SPM12.zip \ + && unzip aal1_for_SPM12.zip \ + && rm -f aal1_for_SPM12.zip \ + && unzip aal_for_SPM12.zip \ + && rm -f aal_for_SPM12.zip \ + && mv aal_for_SPM12/* . \ + && rmdir aal_for_SPM12 \ + && rm -rf __MACOSX \ + && wget --no-check-certificate -qO- http://www.gin.cnrs.fr/wp-content/uploads/aal2_for_SPM12.tar.gz | \ + tar zx --strip-components=1 + +# ADHD200 Downloader +FROM base AS adhd200-downloader +WORKDIR /opt +RUN wget --no-check-certificate -qO- http://www.nitrc.org/frs/download.php/5906/ADHD200_parcellations.tar.gz | \ + tar zx \ + && rm -f ADHD200_parcellations.tar.gz + +# BrainNetome Downloader +FROM base AS brainnetome-downloader +WORKDIR /opt/brainnetome +RUN \ + ( curl https://pan.cstcloud.cn/unode/stor/downloadByUrl?downloadId=1.eyJidWNrZXQiOiJkZWZhdWx0IiwibGVuIjozODIzMDY1Nywic2l6ZSI6MzgyMzA2NTcsInBvcyI6MCwibmFtZSI6InJoLkJOX0F0bGFzLmdjcyIsImN0aW1lIjoxNzYxNjE3OTkyLCJrZXkiOiJzTEhJaHcxYkZYZ0RTeGRld3I0ZWtxRjJuQ0VBQUFBQ1IxcUIiLCJhZ2UiOjg2NDAwfQ.3670708009 \ + -o rh.BN_Atlas.gcs || \ + wget --no-check-certificate -q "https://osf.io/e6zkg/download" -O rh.BN_Atlas.gcs ) \ + && ( curl https://pan.cstcloud.cn/unode/stor/downloadByUrl?downloadId=1.eyJidWNrZXQiOiJkZWZhdWx0IiwibGVuIjozNjQ5NzM5OSwic2l6ZSI6MzY0OTczOTksInBvcyI6MCwibmFtZSI6ImxoLkJOX0F0bGFzLmdjcyIsImN0aW1lIjoxNzYxNjE3OTEwLCJrZXkiOiJsMzFISVJNZ3FScXZlREg4S2RPVjRqUXNVRnNBQUFBQ0xPZjMiLCJhZ2UiOjg2NDAwfQ.4292748252 \ + -o lh.BN_Atlas.gcs || \ + wget --no-check-certificate -q "https://osf.io/af9ut/download" -O lh.BN_Atlas.gcs ) \ + && ( curl https://pan.cstcloud.cn/unode/stor/downloadByUrl?downloadId=1.eyJidWNrZXQiOiJkZWZhdWx0IiwibGVuIjozNzAxODQwOCwic2l6ZSI6MzcwMTg0MDgsInBvcyI6MCwibmFtZSI6IkJOX0F0bGFzX3N1YmNvcnRleC5nY2EiLCJjdGltZSI6MTc2MTYxODA2Niwia2V5IjoicEhiTXdwbjBKRUd4cmJ2Z0NScDFTdzNsS2RrQUFBQUNOTnNvIiwiYWdlIjo4NjQwMH0.1891919902 \ + -o BN_Atlas_subcortex.gca || \ + wget --no-check-certificate -q "https://osf.io/k2cd8/download" -O BN_Atlas_subcortex.gca ) \ + && ( curl https://pan.cstcloud.cn/unode/stor/downloadByUrl?downloadId=1.eyJidWNrZXQiOiJkZWZhdWx0IiwibGVuIjo1ODAyLCJzaXplIjo1ODAyLCJwb3MiOjAsIm5hbWUiOiJCTl9BdGxhc18yNDZfTFVULnR4dCIsImN0aW1lIjoxNzYxNjE4MDkwLCJrZXkiOiJ1bGV4dG1Kd0hJeGNjdVFDZWwzdTZnUVJXUThBQUJhcSIsImFnZSI6ODY0MDAsInBhcnRPbmUiOnsic2l6ZSI6NTgwMiwiZm4iOiJoa194MmRaLVFYZy0wLTU4MDIiLCJjcmMzMiI6ODk4MzQwOTA0LCJiaWQiOjEsImNpZCI6MX19.3302844208 \ + -o BN_Atlas_246_LUT.txt || \ + wget --no-check-certificate -q "https://osf.io/eb7pm/download" -O BN_Atlas_246_LUT.txt ) \ + && ( curl https://pan.cstcloud.cn/unode/stor/downloadByUrl?downloadId=1.eyJidWNrZXQiOiJkZWZhdWx0IiwibGVuIjoxNzEyMzMsInNpemUiOjE3MTIzMywicG9zIjowLCJuYW1lIjoiQk5BX01QTV90aHIyNV8xLjI1bW0ubmlpLmd6IiwiY3RpbWUiOjE3NjE2MTk3NzcsImtleSI6IlFrbzBFajRIRTEyS1U4N0tBOXdVZlVMX1RIY0FBcHpoIiwiYWdlIjo4NjQwMCwicGFydE9uZSI6eyJzaXplIjoxNzEyMzMsImZuIjoibDlsX3ZVVkRRMDAtMC0xNzEyMzMiLCJjcmMzMiI6MjY1NjI1NTM0NywiYmlkIjoxLCJjaWQiOjF9fQ.2692886444 \ + -o BNA_MPM_thr25_1.25mm.nii.gz || \ + wget --no-check-certificate -q "https://osf.io/dbqep/download" -O BNA_MPM_thr25_1.25mm.nii.gz ) + +# MNI Downloader +FROM base AS mni512-downloader +WORKDIR /opt +RUN wget --no-check-certificate -q https://github.com/AlistairPerry/CCA/raw/master/parcellations/512inMNI.nii + +# Yeo2011 Downloader +FROM base AS yeo2011-downloader +WORKDIR /opt/Yeo2011 +RUN wget --no-check-certificate -qO- "https://github.com/ThomasYeoLab/CBIG/archive/v0.11.1-Wu2017_RegistrationFusion.tar.gz" | \ + tar zx \ + && mkdir -p freesurfer/subjects/fsaverage5/label \ + && cp CBIG-0.11.1-Wu2017_RegistrationFusion/stable_projects/brain_parcellation/Yeo2011_fcMRI_clustering/1000subjects_reference/Yeo_JNeurophysiol11_SplitLabels/fsaverage5/label/*h.Yeo2011_*Networks_N1000.split_components.annot freesurfer/subjects/fsaverage5/label/ \ + && cp CBIG-0.11.1-Wu2017_RegistrationFusion/stable_projects/brain_parcellation/Yeo2011_fcMRI_clustering/1000subjects_reference/Yeo_JNeurophysiol11_SplitLabels/project_to_individual/Yeo2011_*networks_Split_Components_LUT.txt freesurfer/ \ + && cp CBIG-0.11.1-Wu2017_RegistrationFusion/stable_projects/brain_parcellation/Yeo2011_fcMRI_clustering/1000subjects_reference/Yeo_JNeurophysiol11_SplitLabels/MNI152/Yeo2011_*Networks_N1000.split_components.FSL_MNI152_*mm.nii.gz . \ + && cp CBIG-0.11.1-Wu2017_RegistrationFusion/stable_projects/brain_parcellation/Yeo2011_fcMRI_clustering/1000subjects_reference/Yeo_JNeurophysiol11_SplitLabels/MNI152/*Networks_ColorLUT_freeview.txt . \ + && rm -rf CBIG-0.11.1-Wu2017_RegistrationFusion + +# ---------- Tool-Specific Stages ---------- +# ANTs Installer +FROM base AS ants-installer +WORKDIR /opt/ants +RUN wget --no-check-certificate -q https://github.com/ANTsX/ANTs/releases/download/v2.6.2/ants-2.6.2-ubuntu18.04-X64-gcc.zip \ + && unzip ants-2.6.2-ubuntu18.04-X64-gcc.zip + +# FreeSurfer Installer +FROM base AS freesurfer-installer +RUN wget --no-check-certificate -qO- https://surfer.nmr.mgh.harvard.edu/pub/dist/freesurfer/7.4.1/freesurfer-linux-centos8_x86_64-7.4.1.tar.gz | \ + tar zx -C /opt \ + --exclude='freesurfer/trctrain' \ + --exclude='freesurfer/subjects/fsaverage_sym' \ + --exclude='freesurfer/subjects/fsaverage3' \ + --exclude='freesurfer/subjects/fsaverage4' \ + --exclude='freesurfer/subjects/fsaverage6' \ + --exclude='freesurfer/subjects/cvs_avg35' \ + --exclude='freesurfer/subjects/cvs_avg35_inMNI152' \ + --exclude='freesurfer/subjects/bert' \ + --exclude='freesurfer/subjects/V1_average' \ + --exclude='freesurfer/average/mult-comp-cor' \ + --exclude='freesurfer/lib/cuda' \ + --exclude='freesurfer/lib/qt' +RUN wget --no-check-certificate -q "https://s3-eu-west-1.amazonaws.com/pfigshare-u-files/5528816/lh.HCPMMP1.annot" \ + -O /opt/freesurfer/subjects/fsaverage/label/lh.HCPMMP1.annot \ + && wget --no-check-certificate -q "https://s3-eu-west-1.amazonaws.com/pfigshare-u-files/5528819/rh.HCPMMP1.annot" \ + -O /opt/freesurfer/subjects/fsaverage/label/rh.HCPMMP1.annot +RUN echo "cHJpbnRmICJyb2JlcnQuc21pdGhAZmxvcmV5LmVkdS5hdVxuMjg1NjdcbiAqQ3FLLjFwTXY4ZE5rXG4gRlNvbGRZRXRDUFZqNlxuIiA+IC9vcHQvZnJlZXN1cmZlci9saWNlbnNlLnR4dAo=" | base64 -d | sh + +# FSL Installer +FROM base AS fsl-installer +WORKDIR / +RUN wget -q http://fsl.fmrib.ox.ac.uk/fsldownloads/fslinstaller.py \ + && chmod 775 fslinstaller.py \ + && python3 /fslinstaller.py -d /opt/fsl -V 6.0.7.18 + +# MRtrix3 Installer: MRtrix3 #3029 as at 2025-10-21 +FROM base AS mrtrix-installer +WORKDIR /opt/mrtrix3 +RUN apt-get install -y \ + build-essential \ + cmake \ + git \ + libfftw3-dev \ + ninja-build \ + pkg-config \ + zlib1g-dev +RUN git clone https://github.com/MRtrix3/mrtrix3.git . \ + && git checkout 26965d57b374a733ac0c583d3b92bad17923128a +RUN git clone https://github.com/Lestropie/dwidenoise2.git dwidenoise2 \ + && cd dwidenoise2 \ + && git checkout a2da5b32876256499a383a52494ca6564009ca85 \ + && cd ../ \ + && cp -r dwidenoise2/cpp . +COPY mrtrix3_connectome/ /opt/mrtrix3/python/mrtrix3/commands/mrtrix3_connectome +RUN cmake -Bbuild -GNinja --preset=release -DMRTRIX_BUILD_GUI=OFF \ + && cmake --build build + +# ROBEX Installer +FROM base AS robex-installer +WORKDIR /opt/robex +RUN wget --no-check-certificate -qO- "https://www.nitrc.org/frs/download.php/5994/ROBEXv12.linux64.tar.gz//?i_agree=1&download_now=1" | \ + tar zx + +# Geolab Installer +FROM base AS geolab-installer +WORKDIR /opt/geolab +RUN apt-get install -y \ + build-essential \ + git \ + libboost-all-dev \ + libeigen3-dev \ + libncurses5-dev \ + libncursesw5-dev +RUN pixi init \ + && pixi add \ + python==3.12 \ + numpy \ + && pixi add --pypi \ + setproctitle>=1.2.3 \ + fitter>=1.5.1 \ + nibabel>=3.2.2 \ + && pixi add \ + ncurses \ + boost \ + eigen \ + openmp \ + && pixi add \ + gcc_linux-64 \ + gxx_linux-64 \ + cmake==3.20.2 \ + make pkg-config +RUN git clone --depth 1 https://github.com/vindasna/GeoLab ./code \ + && cd code \ + && git fetch --depth 1 origin f5de43c05b3eeb20823f647d271f6e845c4f6d4b \ + && git checkout f5de43c05b3eeb20823f647d271f6e845c4f6d4b +RUN rm -r code/build \ + && mkdir code/build \ + && cd code/build \ + && pixi run cmake .. \ + && pixi run make -j"$(nproc)" +COPY ./Atlas/short /opt/geolab/atlas/short +RUN echo '\n[activation.env]' >> pixi.toml \ + && echo 'PATH="/opt/geolab/code/build/bin:${PATH}"' >> pixi.toml \ + && echo 'PYTHONPATH="/opt/geolab/code:${PYTHONPATH}"' >> pixi.toml \ + && echo 'ESBA_DIR="/opt/geolab/atlas/short"' >> pixi.toml + +# Tractseg Installer +FROM base AS tractseg-installer +WORKDIR /opt/tractseg +RUN ls /root/.pixi/bin +RUN pixi init \ + && pixi add \ + python==3.12 \ + dipy==1.12.1 \ + libgl \ + xorg-libxrender \ + && pixi add --pypi \ + torch==2.11.0 \ + TractSeg==2.10 + +# Synb0 Installer +FROM base AS synb0-installer +WORKDIR /opt/convert3d +RUN wget --no-check-certificate -O c3d-1.0.0-Linux-x86_64.tar.gz "https://downloads.sourceforge.net/project/c3d/c3d/1.0.0/c3d-1.0.0-Linux-x86_64.tar.gz?r=https%3A%2F%2Fsourceforge.net%2Fprojects%2Fc3d%2Ffiles%2Fc3d%2F1.0.0%2Fc3d-1.0.0-Linux-x86_64.tar.gz%2Fdownload&ts=1571934949" \ + && tar -xf c3d-1.0.0-Linux-x86_64.tar.gz \ + && rm c3d-1.0.0-Linux-x86_64.tar.gz +WORKDIR /opt/synb0 +RUN pixi init \ + && pixi add \ + python==3.6.15 \ + && pixi add --pypi \ + nibabel==2.4.0 \ + numpy==1.16.3 \ + Pillow==6.0.0 \ + # pkg-resources==0.0.0 \ + scipy==1.3.0 \ + six==1.12.0 \ + torch==1.1.0 \ + torchvision==0.2.2.post3 +COPY synb0/ . + +# ---------- Final Stage ---------- +FROM base AS final + +# Install system dependencies (minimal) +RUN apt-get -qq update \ + && apt-get install -yq --no-install-recommends \ + bc \ + dc \ + git \ + libfftw3-single3 \ + libfftw3-double3 \ + nano \ + nodejs \ + npm \ + tcsh \ + && apt-get autoremove -y \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +# Copy artifacts from builder stages +COPY --from=aal-downloader /opt/aal /opt/aal +COPY --from=adhd200-downloader /opt/ADHD200_parcellate_200.nii.gz /opt/ADHD200_parcellate_200.nii.gz +COPY --from=adhd200-downloader /opt/ADHD200_parcellate_400.nii.gz /opt/ADHD200_parcellate_400.nii.gz +COPY --from=ants-installer /opt/ants/ants-2.6.2 /opt/ants +COPY --from=brainnetome-downloader /opt/brainnetome/rh.BN_Atlas.gcs /opt/freesurfer/average/rh.BN_Atlas.gcs +COPY --from=brainnetome-downloader /opt/brainnetome/lh.BN_Atlas.gcs /opt/freesurfer/average/lh.BN_Atlas.gcs +COPY --from=brainnetome-downloader /opt/brainnetome/BN_Atlas_subcortex.gca /opt/freesurfer/average/BN_Atlas_subcortex.gca +COPY --from=brainnetome-downloader /opt/brainnetome/BN_Atlas_246_LUT.txt /opt/brainnetome/BN_Atlas_246_LUT.txt +COPY --from=brainnetome-downloader /opt/brainnetome/BN_Atlas_246_LUT.txt /opt/freesurfer/BN_Atlas_246_LUT.txt +COPY --from=brainnetome-downloader /opt/brainnetome/BNA_MPM_thr25_1.25mm.nii.gz /opt/brainnetome/BNA_MPM_thr25_1.25mm.nii.gz +COPY --from=mni512-downloader /opt/512inMNI.nii /opt/512inMNI.nii +COPY --from=yeo2011-downloader /opt/Yeo2011 /opt/Yeo2011 +COPY --from=freesurfer-installer /opt/freesurfer /opt/freesurfer +COPY --from=fsl-installer /opt/fsl /opt/fsl +COPY --from=mrtrix-installer /opt/mrtrix3 /opt/mrtrix3 +COPY --from=robex-installer /opt/robex /opt/robex +COPY --from=geolab-installer /opt/geolab /opt/geolab +COPY --from=tractseg-installer /opt/tractseg /opt/tractseg +COPY --from=synb0-installer /opt/synb0 /opt/synb0 +COPY --from=synb0-installer /opt/convert3d /opt/convert3d + +# Move Yeo2011 files to FreeSurfer +RUN mv /opt/Yeo2011/freesurfer/subjects/fsaverage5/label/* /opt/freesurfer/subjects/fsaverage5/label \ + && mv /opt/Yeo2011/freesurfer/*.* /opt/freesurfer/ \ + && rm -rf /opt/Yeo2011/freesurfer + +# Install bids-validator +RUN npm install -gq bids-validator@1.15.0 + +# Geolab command +RUN echo '#!/bin/bash' > /usr/local/bin/ProjectAtlasGeoLab && \ + echo 'pixi run -m /opt/geolab/pixi.toml ProjectAtlasGeoLab "$@"' >> /usr/local/bin/ProjectAtlasGeoLab && \ + chmod +x /usr/local/bin/ProjectAtlasGeoLab +RUN echo '#!/bin/bash' > /usr/local/bin/ConvertBundleFormat && \ + echo 'pixi run -m /opt/geolab/pixi.toml ConvertBundleFormat "$@"' >> /usr/local/bin/ConvertBundleFormat && \ + chmod +x /usr/local/bin/ConvertBundleFormat + +# Tractseg command +RUN echo '#!/bin/bash' > /usr/local/bin/TractSeg && \ + echo 'pixi run --as-is -m /opt/tractseg/pixi.toml TractSeg "$@"' >> /usr/local/bin/TractSeg && \ + chmod +x /usr/local/bin/TractSeg +RUN echo '#!/bin/bash' > /usr/local/bin/Tracking && \ + echo 'pixi run --as-is -m /opt/tractseg/pixi.toml Tracking "$@"' >> /usr/local/bin/Tracking && \ + chmod +x /usr/local/bin/Tracking +RUN echo '#!/bin/bash' > /usr/local/bin/Tractometry && \ + echo 'pixi run --as-is -m /opt/tractseg/pixi.toml Tractometry "$@"' >> /usr/local/bin/Tractometry && \ + chmod +x /usr/local/bin/Tractometry + +# Synb0 command +ENV PATH="/opt/convert3d/c3d-1.0.0-Linux-x86_64/bin:$PATH" +RUN echo '#!/bin/bash' > /usr/local/bin/synb0 && \ + echo 'pixi run --as-is -m /opt/synb0/pixi.toml /opt/synb0/synb0.sh "$@"' >> /usr/local/bin/synb0 && \ + chmod +x /usr/local/bin/synb0 + +# Install BrainPrep & AMICO +WORKDIR /opt/brainprep +COPY pixi.toml . +RUN pixi install \ + && pixi add --pypi \ + dmri-amico==2.1.1 +RUN ln -s /opt/brainprep/.pixi/envs/default/bin/brainprep /usr/local/bin/brainprep + +# Environment Variables +ENV ANTSPATH=/opt/ants \ + FREESURFER_HOME=/opt/freesurfer \ + FMRI_ANALYSIS_DIR=/opt/freesurfer/fsfast \ + FSF_OUTPUT_FORMAT=nii.gz \ + FSFAST_HOME=/opt/freesurfer/fsfast \ + LOCAL_DIR=/opt/freesurfer/local \ + MINC_BIN_DIR=/opt/freesurfer/mni/bin \ + MINC_LIB_DIR=/opt/freesurfer/mni/lib \ + MNI_DATAPATH=/opt/freesurfer/mni/data \ + MNI_DIR=/opt/freesurfer/mni \ + MNI_PERL5LIB=/opt/freesurfer/mni/lib/perl5/5.8.5 \ + OS=Linux \ + PERL5LIB=/opt/freesurfer/mni/lib/perl5/5.8.5 \ + SUBJECTS_DIR=/opt/freesurfer/subjects \ + FSLDIR=/opt/fsl \ + FSLOUTPUTTYPE=NIFTI \ + FSLMULTIFILEQUIT=TRUE \ + FSLTCLSH=/opt/fsl/bin/fsltclsh \ + FSLWISH=/opt/fsl/bin/fslwish \ + ESBA_DIR=/opt/geolab/atlas/short \ + PATH=/opt/mrtrix3/build/bin:/opt/ants/bin:/opt/freesurfer/bin:/opt/freesurfer/mni/bin:/opt/fsl/bin:/opt/ROBEX:/opt/geolab/code/build/bin:$PATH \ + PYTHONPATH=/opt/mrtrix3/lib + +# Entrypoint +COPY entrypoint.sh /opt/entrypoint.sh +RUN chmod +x /opt/entrypoint.sh +ENTRYPOINT ["/opt/entrypoint.sh"] +ENV DEFAULT_ARGS="subject-level-dmriprep group-level-dmriprep" + +# Metadata +LABEL org.opencontainers.image.title="BrainPrep dMRI PreProcessing" \ + org.opencontainers.image.description="Container for BrainPrep dMRI PreProcessing using MrTrix3, GeoLab, TractSeg, FSL, Amico, SynB0" \ + org.opencontainers.image.version="v2" \ + org.opencontainers.image.licenses="CeCILL-B" \ + org.opencontainers.image.authors="CEA/NeuroSpin/GAIA" \ + org.opencontainers.image.documentation="https://brainprepdesk.github.io/brainprep/stable" \ + org.opencontainers.image.source="https://github.com/brainprepdesk/brainprep" \ + org.opencontainers.image.created="2025-01-15" \ + com.brainprep.fsl.version="6.0.7.18" \ + com.brainprep.mrtrix3.version="26965d" \ + com.brainprep.mrtrix3_connectome.version="dev" \ + com.brainprep.geolab.version="f5de43" \ + com.brainprep.tractseg.version="2.10" \ + com.brainprep.amico.version="2.1.1" \ + com.brainprep.synb0.version="dev" diff --git a/containers/Dockerfile.quasiraw b/containers/Dockerfile.quasiraw index 31f7e5c9..97bda3f4 100644 --- a/containers/Dockerfile.quasiraw +++ b/containers/Dockerfile.quasiraw @@ -1,3 +1,5 @@ +FROM freesurfer/synthstrip:1.8 AS synthstrip + FROM ubuntu:22.04 ENV DEBIAN_FRONTEND="noninteractive" \ @@ -5,12 +7,12 @@ ENV DEBIAN_FRONTEND="noninteractive" \ LC_ALL="en_US.UTF-8" RUN apt-get update -qq \ && apt-get install -y -q --no-install-recommends \ - apt-utils \ - bzip2 \ - ca-certificates \ - curl \ - locales \ - unzip \ + apt-utils \ + bzip2 \ + ca-certificates \ + curl \ + locales \ + unzip \ && rm -rf /var/lib/apt/lists/* RUN sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ && dpkg-reconfigure --frontend=noninteractive locales \ @@ -20,8 +22,7 @@ RUN sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ # Parameters ENV ANTS_VERSION="2.6.2" \ FSL_VERSION="6.0.7.19" \ - FS_VERSION="8.1.0" \ - FS_URL="https://surfer.nmr.mgh.harvard.edu/pub/dist/freesurfer/8.1.0/freesurfer_ubuntu22-8.1.0_amd64.deb" \ + FS_SYNTHSTRIP_VERSION="1.8" \ ANTS_URL="https://github.com/ANTsX/ANTs/releases/download/v2.6.2/ants-2.6.2-centos7-X64-gcc.zip" # Install: FSL @@ -37,145 +38,49 @@ ENV FSLDIR="/opt/fsl" \ FSLGECUDAQ="cuda.q" RUN apt-get update -qq \ && apt-get install -y -q --no-install-recommends \ - bc \ - ca-certificates \ - curl \ - dc \ - file \ - libfontconfig1 \ - libfreetype6 \ - libgl1-mesa-dev \ - libgl1-mesa-dri \ - libglu1-mesa-dev \ - libgomp1 \ - libice6 \ - libopenblas0 \ - libxcursor1 \ - libxft2 \ - libxinerama1 \ - libxrandr2 \ - libxrender1 \ - libxt6 \ - nano \ - python3 \ - sudo \ - wget \ - && rm -rf /var/lib/apt/lists/* -RUN echo "Installing FSL ..." \ - && curl -fsSL https://fsl.fmrib.ox.ac.uk/fsldownloads/fslconda/releases/fslinstaller.py | python3 - -d $FSLDIR -V $FSL_VERSION - -# Install: FreeSurfer -ENV OS="Linux" \ - PATH="/opt/freesurfer/bin:/opt/freesurfer/fsfast/bin:/opt/freesurfer/tktools:/opt/freesurfer/mni/bin:$PATH" \ - FREESURFER_HOME="/opt/freesurfer" \ - FREESURFER="/opt/freesurfer" \ - SUBJECTS_DIR="/opt/freesurfer/subjects" \ - LOCAL_DIR="/opt/freesurfer/local" \ - FSFAST_HOME="/opt/freesurfer/fsfast" \ - FMRI_ANALYSIS_DIR="/opt/freesurfer/fsfast" \ - FUNCTIONALS_DIR="/opt/freesurfer/sessions" \ - FS_OVERRIDE="0" \ - FIX_VERTEX_AREA="" \ - FSF_OUTPUT_FORMAT="nii.gz" \ - MINC_BIN_DIR="/opt/freesurfer/mni/bin" \ - MINC_LIB_DIR="/opt/freesurfer/mni/lib" \ - MNI_DIR="/opt/freesurfer/mni" \ - MNI_DATAPATH="/opt/freesurfer/mni/data" \ - MNI_PERL5LIB="/opt/freesurfer/mni/share/perl5" \ - PERL5LIB="/opt/freesurfer/mni/share/perl5" -RUN apt-get update -qq \ - && apt-get install -y -q --no-install-recommends \ - octave \ - wget \ - language-pack-en \ - binutils \ - libx11-dev \ - gettext \ - xterm \ - x11-apps \ - perl \ - make \ - csh \ - tcsh \ - file \ bc \ - xorg \ - xorg-dev \ - xserver-xorg-video-intel \ - libncurses5 \ - libbsd0 \ - libegl1 \ - libexpat1 \ + ca-certificates \ + curl \ + dc \ + file \ libfontconfig1 \ libfreetype6 \ - libgl1 \ - libglib2.0-0 \ - libglu1-mesa \ - libglvnd0 \ - libglx0 \ + libgl1-mesa-dev \ + libgl1-mesa-dri \ + libglu1-mesa-dev \ libgomp1 \ libice6 \ - libicu70 \ - libjpeg62 \ - libmd0 \ - libopengl0 \ - libpcre2-16-0 \ - libpng16-16 \ - libquadmath0 \ - libsm6 \ - libx11-6 \ - libx11-xcb1 \ - libxau6 \ - libxcb-icccm4 \ - libxcb-image0 \ - libxcb-keysyms1 \ - libxcb-randr0 \ - libxcb-render-util0 \ - libxcb-render0 \ - libxcb-shape0 \ - libxcb-shm0 \ - libxcb-sync1 \ - libxcb-util1 \ - libxcb-xfixes0 \ - libxcb-xinerama0 \ - libxcb-xinput0 \ - libxcb-xkb1 \ - libxcb1 \ - libxdmcp6 \ - libxext6 \ + libopenblas0 \ + libxcursor1 \ libxft2 \ - libxi6 \ - libxkbcommon-x11-0 \ - libxkbcommon0 \ - libxmu6 \ + libxinerama1 \ + libxrandr2 \ libxrender1 \ - libxss1 \ libxt6 \ - mesa-utils \ - unzip \ - libncurses5 \ - libgomp1 \ - openjdk-8-jdk \ - xvfb \ - xauth \ + nano \ + python3 \ + sudo \ + wget \ && rm -rf /var/lib/apt/lists/* -RUN export TMPDIR="$(mktemp -d)" -RUN echo "Downloading FreeSurfer ..." \ - && curl -o $TMPDIR/freesurfer.deb $FS_URL -RUN dpkg -i $TMPDIR/freesurfer.deb \ - && rm -rf $TMPDIR \ - && unset TMPDIR -RUN ln -s /usr/local/freesurfer/${FS_VERSION} /opt/freesurfer -RUN fs_install_mcr R2019b +RUN echo "Installing FSL ..." \ + && curl -fsSL https://fsl.fmrib.ox.ac.uk/fsldownloads/fslconda/releases/fslinstaller.py | python3 - -d $FSLDIR -V $FSL_VERSION + +# Install: FreeSurfer SynthStrip +ENV FREESURFER_HOME="/freesurfer" +COPY --from=synthstrip /freesurfer /freesurfer +RUN echo '#!/bin/bash' > /usr/local/bin/mri_synthstrip && \ + echo 'PYTHONPATH="/freesurfer/env/lib/python3.10/site-packages" \\' >> /usr/local/bin/mri_synthstrip && \ + echo ' /usr/bin/python3 /freesurfer/mri_synthstrip "$@"' >> /usr/local/bin/mri_synthstrip && \ + chmod +x /usr/local/bin/mri_synthstrip # Install ANTs ENV ANTSPATH="/opt/ants" \ PATH="/opt/ants:$PATH" RUN apt-get update -qq \ && apt-get install -y -q --no-install-recommends \ - ca-certificates \ - curl \ - unzip \ + ca-certificates \ + curl \ + unzip \ && rm -rf /var/lib/apt/lists/* RUN echo "Downloading ANTs ..." \ && curl -fsSL -o /opt/ants.zip $ANTS_URL @@ -190,7 +95,7 @@ ENV PATH="/root/.pixi/bin:${PATH}" WORKDIR /opt/brainprep RUN apt-get update -qq \ && apt-get install -y -q --no-install-recommends \ - git \ + git \ && rm -rf /var/lib/apt/lists/* COPY pixi.toml . RUN pixi install @@ -210,5 +115,5 @@ LABEL org.opencontainers.image.title="BrainPrep Quasi-Raw" \ org.opencontainers.image.source="https://github.com/brainprepdesk/brainprep" \ org.opencontainers.image.created="2025-01-15" \ com.brainprep.fsl.version="${FSL_VERSION}" \ - com.brainprep.freesurfer.version="${FS_VERSION}" \ + com.brainprep.freesurfer-synthstrip.version="${FS_SYNTHSTRIP_VERSION}" \ com.brainprep.ants.version="${ANTS_VERSION}" diff --git a/containers/Dockerfile.sulcirec b/containers/Dockerfile.sulcirec new file mode 100644 index 00000000..a20dafcd --- /dev/null +++ b/containers/Dockerfile.sulcirec @@ -0,0 +1,58 @@ +FROM ubuntu:24.04 + +ENV LANG="en_US.UTF-8" \ + LC_ALL="en_US.UTF-8" +RUN apt-get update -qq \ + && apt-get install -y -q --no-install-recommends \ + apt-utils \ + bzip2 \ + ca-certificates \ + curl \ + locales \ + unzip \ + && rm -rf /var/lib/apt/lists/* +RUN sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ + && dpkg-reconfigure --frontend=noninteractive locales \ + && update-locale LANG="en_US.UTF-8" \ + && chmod 777 /opt && chmod a+s /opt + +# Parameters +ENV MORPHOLOGIST_VERSION="6.0.19" + +# Install: pixi +RUN curl -fsSL https://pixi.sh/install.sh | bash +ENV PATH="/root/.pixi/bin:${PATH}" + +# Install: morphologist +WORKDIR /opt/morphologist +COPY pixi_morphologist.toml ./pixi.toml +RUN pixi install +RUN echo '#!/usr/bin/env bash' > /usr/local/bin/morphologist-wrapper \ + && echo 'set -e' >> /usr/local/bin/morphologist-wrapper \ + && echo 'pixi run -m /opt/morphologist/pixi.toml --as-is morphologist-cli "$@"' >> /usr/local/bin/morphologist-wrapper \ + && chmod +x /usr/local/bin/morphologist-wrapper + +# Install: brainprep +WORKDIR /opt/brainprep +RUN apt-get update -qq \ + && apt-get install -y -q --no-install-recommends \ + git \ + && rm -rf /var/lib/apt/lists/* +COPY pixi.toml . +RUN pixi install +RUN ln -s /opt/brainprep/.pixi/envs/default/bin/brainprep /usr/local/bin/brainprep + +COPY entrypoint.sh /opt/entrypoint.sh +RUN chmod +x /opt/entrypoint.sh +ENTRYPOINT ["/opt/entrypoint.sh"] +ENV DEFAULT_ARGS="subject-level-sulcirec" + +LABEL org.opencontainers.image.title="BrainPrep Sulci Recognition" \ + org.opencontainers.image.description="Container for BrainPrep Sulci Recognition using Morphologist" \ + org.opencontainers.image.version="v2" \ + org.opencontainers.image.licenses="CeCILL-B" \ + org.opencontainers.image.authors="CEA/NeuroSpin/GAIA" \ + org.opencontainers.image.documentation="https://brainprepdesk.github.io/brainprep/stable" \ + org.opencontainers.image.source="https://github.com/brainprepdesk/brainprep" \ + org.opencontainers.image.created="2026-03-25" \ + com.brainprep.morphologist.version="${MORPHOLOGIST_VERSION}" diff --git a/containers/resources/pixi_morphologist.toml b/containers/resources/pixi_morphologist.toml new file mode 100644 index 00000000..8365cfc4 --- /dev/null +++ b/containers/resources/pixi_morphologist.toml @@ -0,0 +1,19 @@ +[workspace] +channels = [ + "https://brainvisa.info/neuro-forge", + "conda-forge" +] +platforms = ["linux-64"] + +[dependencies] +morphologist = "==6.0.19" + +[activation.env] +LD_PRELOAD = "/opt/morphologist/.pixi/envs/default/mesalib/lib/libglapi.so.0:/opt/morphologist/.pixi/envs/default/mesalib/lib/libGL.so.1" + +[tasks.morphologist-wrapper] +cmd = "bash -c 'echo $LD_PRELOAD'; morphologist-cli" + +[tasks.morphologist-wrapper.env] +LD_PRELOAD = "/opt/morphologist/.pixi/envs/default/mesalib/lib/libglapi.so.0:/opt/morphologist/.pixi/envs/default/mesalib/lib/libGL.so.1" + diff --git a/doc/images/preproc-sulcirec.jpg b/doc/images/preproc-sulcirec.jpg new file mode 100644 index 00000000..c42d8fd5 Binary files /dev/null and b/doc/images/preproc-sulcirec.jpg differ diff --git a/doc/user_guide/cli.rst b/doc/user_guide/cli.rst index c280b4d5..14efaccd 100644 --- a/doc/user_guide/cli.rst +++ b/doc/user_guide/cli.rst @@ -10,7 +10,7 @@ Introduction ------------ ``brainprep`` provides a dynamic, Fire-powered command-line interface that -automatically exposes all workflows as CLI commands. It also +automatically exposes all workflows and interfaces as CLI commands. It also injects global configuration parameters into each workflow function signature, enabling users to override default processing options directly from the command line. @@ -29,3 +29,17 @@ and ``[CONFIG PARAMS]`` are the arguments associated with the context manager. Use ``brainprep --help`` to list all available workflows, or ``brainprep [WORKFLOW] --help`` to display the parameters required by a specific workflow. + +.. code-block:: bash + + brainprep interface [INTERFACE] [INTERFACE PARAMS] [CONFIG PARAMS] + +``[INTERFACE]`` specifies which interface to run, +``[INTERFACE PARAMS]`` are the input arguments associated with that interface, +and ``[CONFIG PARAMS]`` are the arguments associated with the context manager. + +Use ``brainprep interface --help`` to list all available workflows, or +``brainprep interface [INTERFACE] --help`` to display the parameters required +by a specific interface. + + diff --git a/doc/user_guide/defacing.rst b/doc/user_guide/defacing.rst index a27f1c3d..2641e440 100644 --- a/doc/user_guide/defacing.rst +++ b/doc/user_guide/defacing.rst @@ -30,7 +30,7 @@ Description **Processing Steps** - **Defacing T1w image** - We use the UK-Biobank defacing method that is provided as part of the + We use the defacing method that is provided as part of the standard FSL distribution under the command ``fsl_deface`` :footcite:p:`almagro2018deface`. Similar to other established defacing tools, such as ``mri_deface`` :footcite:p:`bischoff2007deface` @@ -40,30 +40,44 @@ Description A key distinction of ``fsl_deface`` compared with ``mri_deface`` and ``pydeface`` is that it additionally removes the ears, providing a more comprehensive anonymization of head anatomy. - This workflow is applied to T1-weighted (T1w) structural images and can be - propagated to other modalities through rigid alignement. + This workflow is applied to T1-weighted structural images. + +- **Defacing T2w and FLAIR images** + The defacing mask obtained from the T1w image is propagated to other + modalities through rigid alignement. **Quality Control** -- **Overlap score** + +- **Correlation score** + For each aligned image, we compute its correlation with the MNI for T1w or + to T1w for T2w and FLAIR reference image. Images are then sorted in + ascending order of this score, allowing potential outliers to be easily + identified. + +- **Overlap score** For each image, we compute the overlap ratio between the brain mask extracted using FreeSurfer's deep‑learning–based ``mri_synthstrip`` method :footcite:p:`hoopes2022brainmask` and the corresponding defacing mask. Images are then sorted in ascending order of this score, allowing potential outliers to be easily identified. -- **Manual inspection** +- **Manual inspection** Following the overlap-based ranking, a manual quality control step is performed using the generated ``defacemosaic`` figure. This figure allows visual inspection of the defaced image and verification that facial/ear structures have been successfully removed. The most obvious outliers are thus removed. -- **Thresholding** +- **Thresholding** + The correlation score is thresholded at 0.5, meaning that if an image is not + roughly registered to the template, the preprocessing is considered invalid. The overlap score is thresholded at 5%, meaning that if the defacing mask removes a substantial portion of the brain, the preprocessing is considered - invalid. Images with an overlap greater than 5% are flagged as low‑quality. + invalid. + Images with a correlation lower than 0.5 or an overlap greater than 5% are + flagged as low‑quality. Outputs ------- @@ -77,45 +91,68 @@ The structure is organized following the :ref:`brainprep ontology `. defacing/ ├── dataset_description.json ├── figures - │   └── histogram_overlap.png + │   ├── histogram_mean_correlation_.png + │   └── histogram_overlap_.rst + │ ├── report_.rst + │ └── commands_.rst ├── quality_check - │   └── mask_overlap.tsv + │   ├── mask_overlap_.tsv + │ └── mean_correlations_.tsv └── subjects └── sub-01 └── ses-01 ├── figures - │   └── sub-01_ses-01_run-01_mod-T1w_defacemosaic.png + │   └── sub-01_ses-01_run-01_mod-_defacemosaic.png ├── log - │   └── report_.rst - ├── sub-01_ses-01_run-01_mod-T1w_defacemask.nii.gz - ├── sub-01_ses-01_run-01_mod-T1w_defacemask.tsv - └── sub-01_ses-01_run-01_mod-T1w_deface.nii.gz + │ ├── report_.rst + │ └── commands_.rst + ├── quality_check + │ ├── sub-01_ses-01_run-01_mod-_corr.tsv + │ └── sub-01_ses-01_run-01_mod-_maskdiff.tsv + ├── sub-01_ses-01_run-01_mod-_affine.mat + ├── sub-01_ses-01_run-01_mod-_defacemask.nii.gz + ├── sub-01_ses-01_run-01_mod-_maskdiff.tsv + └── sub-01_ses-01_run-01_mod-.nii.gz **Description of contents**: - ``dataset_description.json`` Metadata describing the process, including versioning and processing information. -- ``figures/histogram_overlap.png`` +- ``figures/histogram_mean_correlation_.png`` + Image correlation-to-reference image distribution and applied threshold. +- ``figures/histogram_overlap_.png`` Image overlap distribution and applied threshold. -- ``logs/report_.rst`` +- ``logs/report_.rst`` Contains group-level workflow steps and parameters. -- ``quality_check/mask_overlap.tsv`` +- ``log/commands_.rst`` + Contains group-level executed commands. +- ``quality_check/mask_overlap_.tsv`` Table containing the overlap score for each subject/session/run. The table includes a binary ``qc`` column indicating the quality control result. -- ``subjects/sub-/ses-/figures/sub-01_ses-01_run-01_mod-T1w_defacemosaic.png`` +- ``quality_check/mean_correlations_.tsv`` + Table containing the correlation score for each subject/session/run. The + table includes a binary ``qc`` column indicating the quality control result. +- ``subjects/sub-/ses-/figures/sub-01_ses-01_run-01_mod-_defacemosaic.png`` A visual mosaic showing defacing masks on some slices for quick quality check. -- ``subjects/sub-/ses-/logs/report_.rst`` +- ``subjects/sub-/ses-/logs/report_.rst`` Contains subject-level workflow steps and parameters. -- ``subjects/sub-/ses-/sub-01_ses-01_run-01_mod-T1w_defacemask.nii.gz`` - The binary mask identifying voxels removed during defacing (face and ears). -- ``subjects/sub-/ses-/sub-01_ses-01_run-01_mod-T1w_defacemask.tsv`` +- ``subjects/sub-/ses-/log/commands_.rst`` + Contains subject-level executed commands. +- ``subjects/sub-/ses-/quality_check/sub-01_ses-01_run-01_mod-_corr.tsv`` + Table containing the correlation score. +- ``subjects/sub-/ses-/quality_check/sub-01_ses-01_run-01_mod-_maskdiff.tsv`` A table containing voxel counts and physical volumes (in mm³) for the brain/defacing masks and their intersection. -- ``subjects/sub-/ses-/sub-01_ses-01_run-01_mod-T1w_deface.nii.gz`` - The final defaced T1w image with facial/ear structures removed. +- ``subjects/sub-/ses-/sub-01_ses-01_run-01_mod-_affine.txt`` + Affine transformation parameters (12 DOF) used to align the T1w image to the + MNI template or rigid transformation parameters (6 DOF) used to coregister + the T2w or FLAIR image to the T1w image. +- ``subjects/sub-/ses-/sub-01_ses-01_run-01_mod-_defacemask.nii.gz`` + The binary mask identifying voxels removed during defacing (face and ears). +- ``subjects/sub-/ses-/sub-01_ses-01_run-01_mod-.nii.gz`` + The final defaced T1w, T2w or FLAIR image with facial/ear structures removed. Featured examples ----------------- diff --git a/doc/user_guide/fmriprep.rst b/doc/user_guide/fmriprep.rst index 0b7dc01e..4b8d42a6 100644 --- a/doc/user_guide/fmriprep.rst +++ b/doc/user_guide/fmriprep.rst @@ -13,8 +13,8 @@ Introduction Preprocessing of functional MRI (fMRI) data is a crucial step in transforming raw scanner outputs into signals that can be meaningfully interpreted and compared across individuals. Raw fMRI volumes contain a variety of -artifacts and sources of variability—such as head motion, scanner drift, -geometric distortions, and physiological noise—that can obscure the +artifacts and sources of variability - such as head motion, scanner drift, +geometric distortions, and physiological noise - that can obscure the underlying neural activity of interest. A standardized preprocessing workflow addresses these issues by aligning images across time and space, correcting for distortions, removing nuisance signals, and generating @@ -57,7 +57,7 @@ This analysis relies on fMRIPrep's pipeline :footcite:p:`esteban2019fmriprep`. - **Confounding factors** Several confounding time-series were calculated based on the preprocessed BOLD: framewise displacement (FD), DVARS and three region-wise global - signals. FD was computed using two formulations(absolute sum of + signals. FD was computed using two formulations (absolute sum of relative motions, relative root mean square displacement between affines). The three global signals are extracted within the CSF, the WM, and the whole-brain masks. Additionally, a set of physiological regressors were @@ -84,7 +84,6 @@ This analysis relies on fMRIPrep's pipeline :footcite:p:`esteban2019fmriprep`. of voxels around the edge of the brain. - **Surface Preprocessings** - Surface-based preprocessing is enabled through the combination of Functional data are projected onto the subject's fsnative surfaces and then mapped to the fsLR surface space. CIFTI outputs at the 91k resolution are generating, producing dense time series suitable for surface-based analyses. diff --git a/doc/user_guide/index.rst b/doc/user_guide/index.rst index 8b34c27b..995c7f87 100644 --- a/doc/user_guide/index.rst +++ b/doc/user_guide/index.rst @@ -22,6 +22,7 @@ pieces of information throughout the guide. quasiraw.rst vbm.rst sbm.rst + sulcirec.rst fmriprep.rst reporting.rst glossary.rst diff --git a/doc/user_guide/ontology.rst b/doc/user_guide/ontology.rst index 6fa766c1..fdfad817 100644 --- a/doc/user_guide/ontology.rst +++ b/doc/user_guide/ontology.rst @@ -34,7 +34,7 @@ Analysis Levels - `statistic/` for statistical outputs. - These directories contain results derived from multiple subjects and - sessions, often in tabular or summary formats. + sessions, often in tabular format. 3. **Longitudinal Analysis** @@ -44,13 +44,29 @@ Analysis Levels - Contains subject-wise data aggregated across sessions and timepoints. - May include harmonized metrics, trajectory models, or longitudinal statistical outputs. -- Example path: `longitudinal/subject/sub-001/ses-01/`. +- Example path: `longitudinal/subjects/sub-001/ses-01/`. + +Preventing Naming Conflicts +--------------------------- + +In a tool utilizing a non-typed ontology, multiple runs of the same modality +may occur within a single session, potentially leading to naming conflicts. +In such cases, an additional ``run-`` subdirectory is created. +If the run identifier is missing from the filename or if multiple images +of the same modality share the same run identifier, a deterministic +five-digit identifier is generated from the filename using a UUID. This +approach ensures stable and reproducible naming. To guarantee the uniqueness +of run identifiers within the session folder, duplicates are checked, and a +warning is issued if a conflict is detected. + +- Example path: `subjects/sub-001/ses-01/run-38549`. Metadata and Logs ----------------- A `dataset_description.json` file is placed at the root of the dataset. This file documents: + - The tool or pipeline used for processing. - The version of the tool. - The container technology used (Docker image), if applicable. @@ -58,6 +74,7 @@ file documents: A `log/` directory is maintained at the root level to store logs from all processing steps. One key log file is `report_.rst`, which provides a comprehensive report of: + - All processing steps executed. - Input and output file locations for each step. - Runtime information for each step. @@ -87,13 +104,3 @@ The overall structure can be visualized as: ├── log/ │ └── report.rst └── dataset_description.json - -In a tool that relies on a non-typed ontology, multiple runs of the same -modality may occur within a single session, which can lead to naming -conflicts. In such cases, an additional ``run-`` subdirectory is created. -When the run identifier is missing from the filename, or when several images -of the same modality share the same run identifier, a deterministic five-digit -identifier is generated from the filename using a UUID. -This ensures stable and reproducible naming. -To guarantee uniqueness of run identifiers within the session folder, we -check for duplicates and issues a warning if a conflict is detected. diff --git a/doc/user_guide/quality_assurance.rst b/doc/user_guide/quality_assurance.rst index b86137c6..35f82701 100644 --- a/doc/user_guide/quality_assurance.rst +++ b/doc/user_guide/quality_assurance.rst @@ -35,7 +35,7 @@ Description - **Image-quality metrics** MRIQC :footcite:p:`esteban2017mriqc` is used to automatically extract - image-quality metrics (IQMs) from structural (T1w, T2w), functional + image-quality metrics (IQMs) from structural (T1w, T2w, FLAIR), functional (BOLD), and diffusion (EPI) MRI scans. Its purpose is to detect artifacts, inconsistencies, and outliers without requiring manual inspection of every image. diff --git a/doc/user_guide/quasiraw.rst b/doc/user_guide/quasiraw.rst index acb9e68c..40b543b9 100644 --- a/doc/user_guide/quasiraw.rst +++ b/doc/user_guide/quasiraw.rst @@ -12,7 +12,8 @@ Introduction ------------ Minimally preprocessed data are generated using a standardized sequence of -lightweight processing steps applied to the raw T1-weighted (T1w) MRI images. +lightweight processing steps applied to the raw T1-weighted (T1w), T2-weighted +(T2w) or FLAIR MRI images. This workflow combines skull stripping, bias field correction, and spatial normalization using widely adopted neuroimaging tools. This minimal preprocessing pipeline ensures that the data are standardized and @@ -25,7 +26,7 @@ Requirements +------------+--------------+ | CPU | RAM | +============+==============+ -| 1 | 5 GB | +| 1 | 16 GB | +------------+--------------+ Description @@ -46,7 +47,8 @@ Description Spatial alignment is carried out using FSL FLIRT :footcite:p:`jenkinson2001flirt` with a 9‑degree‑of‑freedom (DOF) affine transformation (translations, rotations, and scaling; no shearing). This step - registers the T1w image to the MNI template while preserving overall anatomy. + registers a T1w, T2w or FLAIR image to the MNI template while preserving + overall anatomy. **Quality Control** @@ -56,8 +58,7 @@ Description outliers to be easily identified. - **Manual inspection** - Following the correlation-based ranking, generated ``T1w`` images at the - lower end of the distribution are manually reviewed in-house. This step is + Generated ``T1w`` images are manually reviewed in-house. This step is performed using a PCA‑based reduction technique to detect the most obvious outliers, which are then removed. @@ -78,48 +79,57 @@ The structure is organized following the :ref:`brainprep ontology `. quasiraw/ ├── dataset_description.json ├── figures - │   ├── histogram_mean_correlation.png - │   └── pca.png + │   ├── histogram_mean_correlation_.png + │   └── pca_.png ├── log - │ └── report_.rst + │ ├── report_.rst + │ └── commands_.rst ├── quality_check - │ ├── mean_correlations.tsv - │ └── pca.tsv + │ ├── mean_correlations_.tsv + │ └── pca_.tsv └── subjects └── sub-01 └── ses-01 ├── log - │ └── report_.rst - ├── sub-01_ses-01_run-01_mod-T1w_affine.txt - ├── sub-01_ses-01_run-01_mod-T1w_brainmask.nii.gz - └── sub-01_ses-01_run-01_T1w.nii.gz + │ ├── report_.rst + │ └── commands_.rst + ├── quality_check + │ └── sub-01_ses-01_run-01_mod-_corr.tsv + ├── sub-01_ses-01_run-01_mod-_affine.txt + ├── sub-01_ses-01_run-01_mod-_brainmask.nii.gz + └── sub-01_ses-01_run-01_.nii.gz **Description of contents**: - ``dataset_description.json`` Metadata describing the process, including versioning and processing information. -- ``figures/histogram_mean_correlation.png`` +- ``figures/histogram_mean_correlation_.png`` Image correlation-to-template distribution and applied threshold. -- ``figures/pca.png`` +- ``figures/pca_.png`` Display of the first two PCA components of the generated images. - ``log/report_.rst`` Contains group-level workflow steps and parameters. -- ``quality_check/mask_overlap.tsv`` +- ``log/commands_.rst`` + Contains group-level executed commands. +- ``quality_check/mean_correlations_.tsv`` Table containing the correlation score for each subject/session/run. The table includes a binary ``qc`` column indicating the quality control result. -- ``quality_check/pca.tsv`` +- ``quality_check/pca_.tsv`` Table containing information on the first two PCA components. -- ``subjects/sub-/ses-/log/report_.rst`` +- ``subjects/sub-/ses-/log/report_.rst`` Contains subject-level workflow steps and parameters. -- ``subjects/sub-/ses-/sub-01_ses-01_run-01_mod-T1w_affine.txt`` - Affine transformation parameters (9 DOF) used to align the T1w image to - the MNI template. -- ``subjects/sub-/ses-/sub-01_ses-01_run-01_mod-T1w_brainmask.nii.gz`` +- ``subjects/sub-/ses-/log/commands_.rst`` + Contains subject-level executed commands. +- ``subjects/sub-/ses-/quality_check/sub-01_ses-01_run-01_mod-_corr.tsv`` + Table containing the correlation score. +- ``subjects/sub-/ses-/sub-01_ses-01_run-01_mod-_affine.txt`` + Affine transformation parameters (9 DOF) used to align the T1w, T2w or FLAIR + image to the MNI 1 mm template. +- ``subjects/sub-/ses-/sub-01_ses-01_run-01_mod-_brainmask.nii.gz`` Brain mask generated during skull stripping (e.g., via SynthStrip). -- ``subjects/sub-/ses-/sub-01_ses-01_run-01_T1w.nii.gz`` - The minimally preprocessed T1w image, including skull stripping, bias - correction, and affine alignment. +- ``subjects/sub-/ses-/sub-01_ses-01_run-01_.nii.gz`` + The minimally preprocessed T1w, T2w or FLAIR image in the MNI 1mm space. Featured examples ----------------- diff --git a/doc/user_guide/sbm.rst b/doc/user_guide/sbm.rst index 7a33eb13..fae741f2 100644 --- a/doc/user_guide/sbm.rst +++ b/doc/user_guide/sbm.rst @@ -14,7 +14,7 @@ Surface-based morphometry (SBM) is a structural MRI analysis framework that focuses on the geometry of the cerebral cortex rather than on voxelwise tissue properties. Using T1w images, SBM reconstructs the white matter and pial surfaces, enabling precise measurement of cortical features such as thickness, -surface area, curvature, and folding patterns. SBM provides offers improved +surface area, curvature, and folding patterns. SBM offers improved sensitivity to subtle cortical differences relative to traditional voxel-based approaches. This makes SBM a widely used tool for studying neurodevelopment, aging, and disease‑related cortical alterations. @@ -28,10 +28,10 @@ Requirements | 1 | 16 GB | +------------+--------------+ -Using Nextflow requires 64 GB of memory and is therefore packaged separately +Using Nextbrain requires 64 GB of memory and is therefore packaged separately within the subject‑level workflow. -Running the workflow requires a CPU with **AVX** or **AVX2** support +Running the workflow requires a CPU with **AVX** or **AVX2** support. You can check this with:: lscpu | grep -i avx @@ -71,7 +71,7 @@ Description - **Euler score** We use the Euler number as an image-quality metric and retain only those with values greater than −217, following the recommendation of - :footcite:p:rosen2018. This filtering step ensures that images with + :footcite:p:`rosen2018`. This filtering step ensures that images with insufficient topological stability or degradation are flagged as low‑quality. Outputs diff --git a/doc/user_guide/sulcirec.rst b/doc/user_guide/sulcirec.rst new file mode 100644 index 00000000..8da44f7a --- /dev/null +++ b/doc/user_guide/sulcirec.rst @@ -0,0 +1,220 @@ +.. _sulcirec: + +Sulci Identification Workflow +============================= + +.. image:: ../images/preproc-sulcirec.jpg + :width: 50% + :align: center + +Introduction +------------ + +Preprocessing MRI data is a crucial step in transforming raw scanner outputs +into signals that can be meaningfully interpreted and compared across +individuals. This task is especially complex when studying the human brain +folding patterns. Every person's folding pattern is unique, like a fingerprint. +The primary challenge is that these folds (sulci) change shape, split, or +merge drastically from one person to another. Successfully mapping these +folds is of scientific and clinical interest; by precisely measuring their +depth and width, doctors can spot early brain shrinking in diseases like +Alzheimer's. Furthermore, accurate sulcal mapping helps scientists understand +how the brain develops. + +Requirements +------------ + ++------------+--------------+ +| CPU | RAM | ++============+==============+ +| 1 | 1 GB | ++------------+--------------+ + +Description +----------- + +**Processing Steps** + +This analysis relies on morphologist's pipeline +:footcite:p:`fischer2012morphologist`. + +- **Automate High‑Quality Brain Segmentation** + The preprocessing performs full anatomical segmentation of T1 MRI data, + including gray matter, white matter, and cerebrospinal fluid. This + provides the foundation for downstream morphometric analyses. + +- **Extract and Analyze Cortical Surfaces** + The tool reconstructs cortical surfaces and generates meshes. + +- **Perform Detailed Sulcal Morphometry** + The sulcal analysis, includes sulcus recognition (now CNN‑based), + sulcal depth, length, and span, gyrification index, and cortical fold graph + construction. + +**Quality Control**: + +- **Sulcal Morphometry scores** + Images were classified as motion outliers when they exceeded established + thresholds for these metrics. Specifically, any volume with + a mean framewise displacement greater than 0.2 mm, or with a mean + standardized DVARS value exceeding 1.5, was flagged as low‑quality. + +- **Manual inspection** + Subject‑level quality‑control HTML reports are reviewed manually to ensure + that preprocessing outcomes are consistent across participants and that no + systematic artifacts remain. + +Outputs +------- + +The ``sulcirec`` directory contains subject-level results, logs, and +quality-control outputs. +The structure is organized following the :ref:`brainprep ontology `. + +.. code-block:: text + + sulcirec/ + ├── dataset_description.json + └── subjects + └── sub-01 + └── ses-00 + ├── log + │   └── report_20260409_112204.rst + └── run-28236 + ├── anat + │   ├── folds + │   │   └── 3.1 + │   │   ├── sub-01_ses-00_run-28236_hemi-L.arg + │   │   ├── sub-01_ses-00_run-28236_hemi-L.data + │   │   │   ├── aims_Tmtktri.gii + │   │   │   ├── bottom_Bucket.bck + │   │   │   ├── cortical_Bucket.bck + │   │   │   ├── junction_Bucket.bck + │   │   │   ├── other_Bucket.bck + │   │   │   ├── plidepassage_Bucket.bck + │   │   │   └── ss_Bucket.bck + │   │   ├── sub-01_ses-00_run-28236_hemi-L_sulcivoronoi.nii.gz + │   │   ├── sub-01_ses-00_run-28236_hemi-R.arg + │   │   ├── sub-01_ses-00_run-28236_hemi-R.data + │   │   │   ├── aims_Tmtktri.gii + │   │   │   ├── bottom_Bucket.bck + │   │   │   ├── cortical_Bucket.bck + │   │   │   ├── junction_Bucket.bck + │   │   │   ├── other_Bucket.bck + │   │   │   ├── plidepassage_Bucket.bck + │   │   │   └── ss_Bucket.bck + │   │   ├── sub-01_ses-00_run-28236_hemi-R_sulcivoronoi.nii.gz + │   │   └── sul-0_auto + │   │   ├── sub-01_ses-00_run-28236_sul-0_auto_sulcal_morphometry.csv + │   │   ├── sub-01_ses-00_run-28236_sul-0_hemi-L_auto.arg + │   │   ├── sub-01_ses-00_run-28236_sul-0_hemi-L_auto.data + │   │   │   ├── aims_Tmtktri.gii + │   │   │   ├── bottom_Bucket.bck + │   │   │   ├── cortical_Bucket.bck + │   │   │   ├── junction_Bucket.bck + │   │   │   ├── other_Bucket.bck + │   │   │   ├── plidepassage_Bucket.bck + │   │   │   ├── ss_Bucket.bck + │   │   ├── sub-01_ses-00_run-28236_sul-0_hemi-R_auto.arg + │   │   └── sub-01_ses-00_run-28236_sul-0_hemi-R_auto.data + │   │   ├── aims_Tmtktri.gii + │   │   ├── bottom_Bucket.bck + │   │   ├── cortical_Bucket.bck + │   │   ├── junction_Bucket.bck + │   │   ├── other_Bucket.bck + │   │   ├── plidepassage_Bucket.bck + │   │   └── ss_Bucket.bck + │   ├── mesh + │   │   ├── sub-01_ses-00_run-28236_head.surf.gii + │   │   ├── sub-01_ses-00_run-28236_hemi-L_pial.surf.gii + │   │   ├── sub-01_ses-00_run-28236_hemi-L_white.surf.gii + │   │   ├── sub-01_ses-00_run-28236_hemi-R_pial.surf.gii + │   │   └── sub-01_ses-00_run-28236_hemi-R_white.surf.gii + │   ├── segmentation + │   │   ├── sub-01_ses-00_run-28236_brain.nii.gz + │   │   ├── sub-01_ses-00_run-28236_edges.nii.gz + │   │   ├── sub-01_ses-00_run-28236_hemi-L_cortex.nii.gz + │   │   ├── sub-01_ses-00_run-28236_hemi-L_csf.nii.gz + │   │   ├── sub-01_ses-00_run-28236_hemi-L_grey_white.nii.gz + │   │   ├── sub-01_ses-00_run-28236_hemi-L_gw_interface.nii.gz + │   │   ├── sub-01_ses-00_run-28236_hemi-L_roots.nii.gz + │   │   ├── sub-01_ses-00_run-28236_hemi-L_skeleton.nii.gz + │   │   ├── sub-01_ses-00_run-28236_hemi-R_cortex.nii.gz + │   │   ├── sub-01_ses-00_run-28236_hemi-R_csf.nii.gz + │   │   ├── sub-01_ses-00_run-28236_hemi-R_grey_white.nii.gz + │   │   ├── sub-01_ses-00_run-28236_hemi-R_gw_interface.nii.gz + │   │   ├── sub-01_ses-00_run-28236_hemi-R_roots.nii.gz + │   │   ├── sub-01_ses-00_run-28236_hemi-R_skeleton.nii.gz + │   │   ├── sub-01_ses-00_run-28236_hfiltered.nii.gz + │   │   ├── sub-01_ses-00_run-28236_nobias.han + │   │   ├── sub-01_ses-00_run-28236_nobias.his + │   │   ├── sub-01_ses-00_run-28236_nobias.nii.gz + │   │   ├── sub-01_ses-00_run-28236_skull_stripped.nii.gz + │   │   ├── sub-01_ses-00_run-28236_sul-0_brain_volumes.csv + │   │   ├── sub-01_ses-00_run-28236_variance.nii.gz + │   │   ├── sub-01_ses-00_run-28236_voronoi.nii.gz + │   │   └── sub-01_ses-00_run-28236_whiteridge.nii.gz + │   ├── sub-01_ses-00_run-28236_sul-0_morphologist_report.json + │   ├── sub-01_ses-00_run-28236_sul-0_morphologist_report.pdf + ├── qc.tsv + ├── registration + │   ├── sub-01_ses-00_run-28236_T1w.referential + │   ├── sub-01_ses-00_run-28236_T1w_TO_MNI152.trm + │   └── sub-01_ses-00_run-28236_T1w_TO_Talairach-ACPC.trm + ├── sub-01_ses-00_run-28236.APC + ├── sub-01_ses-00_run-28236_desc-conform_T1w.nii.gz + ├── sub-01_ses-00_run-28236_normalized_SPM.nii + ├── sub-01_ses-00_run-28236_sn.mat + └── sub-01_ses-00_run-28236_sn_pass1.mat + +**Description of contents**: + +- ``dataset_description.json`` + Metadata describing the process, including versioning and processing + information. +- ``log/report_.rst`` + Contains group-level workflow steps and parameters. +- ``quality_check/motion_confounds.tsv`` + Table containing the mean standardized DVARS and mean FD for each + subject/session/run. The table includes a binary ``qc`` column indicating + the quality control result. +- ``subjects/sub-/ses-/logs/report_.rst`` + Contains subject-level workflow steps and parameters. +- ``subjects/sub-/ses-/run-`` + Standard morphologist folder structure (https://brainvisa.info/axon-6.0/en/processes/categories/morphologist/category_documentation.html). + +Featured examples +----------------- + +.. grid:: + + .. grid-item-card:: + :link: ../auto_examples/workflows/plot_sulcirec.html + :link-type: url + :columns: 12 12 12 12 + :class-card: sd-shadow-sm + :margin: 2 2 auto auto + + .. grid:: + :gutter: 3 + :margin: 0 + :padding: 0 + + .. grid-item:: + :columns: 12 4 4 4 + + .. image:: ../auto_examples/workflows/images/thumb/sphx_glr_plot_sulcirec_thumb.png + + .. grid-item:: + :columns: 12 8 8 8 + + .. div:: sd-font-weight-bold + + Sulci reconstruction Pre-Processing + + Explore how to perform this analysis. + +References +---------- + +.. footbibliography:: diff --git a/doc/user_guide/vbm.rst b/doc/user_guide/vbm.rst index 831c0275..23953849 100644 --- a/doc/user_guide/vbm.rst +++ b/doc/user_guide/vbm.rst @@ -43,61 +43,68 @@ Description - **Tissue Segmentation** CAT12 :footcite:p:`gaser2024cat12vbm` performs an advanced segmentation of the brain into gray matter (GM), white matter (WM), and cerebrospinal fluid - (CSF). The tool performs: adaptive local segmentation (LAS) for improved - boundary detection, graph-cut refinement to sharpen tissue borders, - partial volume estimation to model voxels containing mixed tissue types, - white matter hyperintensity correction, and Markov Random Field smoothing to - reduce isolated misclassifications. + (CSF). The pipeline utilizes adaptive local segmentation (LAS) for enhanced + boundary detection, graph-cut refinement to sharpen tissue borders, partial + volume estimation to accurately model voxels containing mixed tissue types, + white matter hyperintensity correction, and Markov Random Field smoothing + to minimize isolated misclassifications. - **Spatial Normalization** - The segmented tissues are registered to a DARTEL template. Both forward and - inverse deformation fields are saved. These allow transforming data between + The segmented tissue maps are registered to an MNI-space template using a + high-dimensional Geodesic Shooting algorithm. Both forward and inverse + deformation fields are saved to enable seamless data transformation between native and template space. - **Modulation** - To preserve local tissue volumes after spatial normalization, CAT12 applies - modulation to the GM, WM, and CSF maps. This step ensures that voxel values - reflect regional volume rather than concentration. + To preserve local tissue volumes following spatial normalization, tissue + maps are adjusted using the Jacobian determinants of the deformation fields. + This modulation step ensures that the final voxel intensities reflect + regional tissue volume rather than tissue concentration. - **Resampling** All normalized images are resampled to 1.5 mm isotropic resolution. - **ROI-Based Morphometry** - Regional measures are extracted using a comprehensive set of atlases, - including: Neuromorphometrics, LPBA40, Hammers, AAL3, Julich Brain, - COBRA, Schaefer 100/200/400/600 parcels, Mori white‑matter atlas, - Anatomy toolbox. For each atlas, CAT12 computes regional volumes. + Regional volumetric and surface metrics are extracted using a comprehensive + suite of atlases, including Neuromorphometrics, LPBA40, Hammers, AAL3, + Julich Brain, COBRA, the Mori white‑matter atlas, and the Anatomy toolbox. + For surface-based parcellations, such as the Schaefer 100/200/400/600 + parcels, CAT12 extracts regional cortical thickness and surface area + measures. **Longitudinal Processing Steps** - **Intra‑subject realignment** - All time points for a participant are rigidly aligned to each other to - remove differences caused by head position rather than true anatomical - change. + All time points for a participant are rigidly aligned to each other using + an inverse-consistent registration fluid dynamics model to remove spatial + differences caused by head position rather than true anatomical change. - **Creation of an unbiased within‑subject template** - CAT12 builds a subject‑specific anatomical template by averaging all time - points in a way that does not favor any single session. This template - serves as a stable reference for all subsequent processing. + CAT12 builds an unbiased subject‑specific anatomical template and a + customized Tissue Probability Map (TPM) by averaging all time points equally. + This template serves as a stable, session-independent reference for all + subsequent processing. - **Bias correction and intensity normalization** - Each time point is corrected for intensity inhomogeneity and normalized - relative to the subject‑specific template, reducing session‑to‑session - variability. + Each time point undergoes adaptive inhomogeneity correction and intensity + normalization relative to the subject‑specific template, significantly + reducing scanner noise and session‑to‑session signal variability. - **Longitudinal segmentation** GM, WM, and CSF are segmented using priors derived from the subject‑specific template. This improves tissue classification consistency across time points. -- **Longitudinal DARTEL registration** +- **Spatial Normalization** All time points are nonlinearly registered to the subject‑specific template, - then to the group template. This two‑stage approach increases sensitivity - to subtle structural changes. + which is then mapped to the MNI standard space using a high-dimensional + Geodesic Shooting algorithm. This two‑stage deformation strategy maximizes + sensitivity to subtle, longitudinal structural changes. - **Modulation** To preserve local tissue volumes after spatial normalization, CAT12 applies - modulation to the GM, WM, and CSF maps. This step ensures that voxel values - reflect regional volume rather than concentration. + modulation to the GM, and WM maps using the Jacobian determinants of the + deformation fields. This step ensures that the final voxel values reflect + regional tissue volume rather than concentration. - **Resampling** All normalized images are resampled to 1.5 mm isotropic resolution. @@ -115,7 +122,7 @@ Description - **Thresholding** Both Noise Contrast Ratio (NCR) and Image Quality Rating (IQR) are - thresholded at a minimum value of 4. Images with NCR < 4 or IQR < 4 are + thresholded at a maximum value of 4.5. Images with NCR > 4.5 or IQR > 4.5 are flagged as low‑quality. Outputs diff --git a/doc/user_guide/wrappers.rst b/doc/user_guide/wrappers.rst index 24211c59..4abf7504 100644 --- a/doc/user_guide/wrappers.rst +++ b/doc/user_guide/wrappers.rst @@ -29,6 +29,9 @@ and expressive way to define interfaces. - Enables automatic metadata extraction - Supports consistent execution patterns +For more implementation details, refer to the +:mod:`API documentation `. + Command-Line Wrappers --------------------- @@ -47,7 +50,7 @@ to interact with external binaries, scripts, or utilities. A command-line wrapper returns: -- A **command** or **list of commands** to be executed. +- A **command** or a **list of commands** to be executed. - A **tuple of generated output paths**. Python Wrappers diff --git a/examples/tools/plot_html_reporting.py b/examples/tools/plot_html_reporting.py index 055d3b47..cf22956c 100644 --- a/examples/tools/plot_html_reporting.py +++ b/examples/tools/plot_html_reporting.py @@ -17,6 +17,7 @@ import pandas as pd from pathlib import Path +from PIL import Image, ImageOps from brainprep.datasets import git_download @@ -35,23 +36,68 @@ "dev/doc/logos/brainprep.png"), destination=working_dir / "im2.png", ) +image = Image.open(working_dir / "im2.png") +inverted_image = ImageOps.invert(image) +inverted_image.save(working_dir / "im2.png") data = [ - { - "name": "Step 1", - "content": working_dir / "im1.png", - "overlay": working_dir / "im2.png", - "tables": pd.DataFrame( - data={'col1': [1, 2], 'col2': [4, 3]} - ), - }, - { - "name": "Step 2", - "content": [ - working_dir / "im1.png", - working_dir / "im2.png", - ], - }, + { + "name": "Step 1", + "images": { + "WithOverlay": { + "record": [ + working_dir / "im1.png", + ], + "overlays": [ + working_dir / "im2.png", + ], + }, + "WithoutOverlay": { + "record": [ + working_dir / "im1.png", + working_dir / "im2.png", + ], + } + }, + "tables": { + "TwoTables": { + "record": [ + pd.DataFrame( + data={'col1': [1, 2], 'col2': [4, 3]} + ), + pd.DataFrame( + data={'col1': [1, 2], 'col2': [4, 3]} + ), + ], + } + }, + }, + { + "name": "Step 2", + "carousels": { + "Carousel": { + "record": [ + working_dir / "im1.png", + working_dir / "im2.png", + ], + "labels": [ + "Im1", + "Im2", + ], + } + }, + "scatters": { + "Scatter": { + "record": [ + {"x": 0, "y": 0, "img": "im1.png"}, + {"x": 1, "y": 1, "img": "im2.png"}, + ], + "x_label": "x", + "y_label": "y", + "with_img": True, + }, + }, + }, ] @@ -64,13 +110,7 @@ from brainprep.reporting import generate_qc_report report = generate_qc_report( - title="Simple QC Example", - docstring=""" - This is a simple example. - - ..note:: - Please adapt this code. - """, + title="Simple Example", version="0.0.0", date="01.01.2000", data=data, diff --git a/examples/workflows/plot_defacing.py b/examples/workflows/plot_defacing.py index 10805e1c..d4eb8c75 100644 --- a/examples/workflows/plot_defacing.py +++ b/examples/workflows/plot_defacing.py @@ -10,20 +10,30 @@ Data ---- -Let's first get some anatomical data. +Let's first get some anatomical data: T1w, T2w and FLAIR.. """ from pathlib import Path +from brainprep.utils import Bunch from brainprep.datasets import OpenMSDataset datadir = Path("/tmp/brainprep-data") datadir.mkdir(parents=True, exist_ok=True) dataset = OpenMSDataset(datadir) -data = dataset.fetch( - subject="01", - modality="T1w", - dtype="cross_sectional", -) +data = Bunch() +for modality in ("T1w", "T2w", "FLAIR"): + data[modality] = Bunch( + sub01=dataset.fetch( + subject="01", + modality=modality, + dtype="cross_sectional", + ), + sub02=dataset.fetch( + subject="02", + modality=modality, + dtype="cross_sectional", + ), + ) print(data) @@ -36,27 +46,31 @@ # However, feel free to set the 'dryrun' configuration to False # to actually run each step and generate results on disk. - +import shutil from brainprep.workflow import ( brainprep_defacing, brainprep_group_defacing, ) from brainprep.config import Config -from brainprep.reporting import RSTReport outdir = Path("/tmp/brainprep-defacing") +if outdir.is_dir(): + shutil.rmtree(outdir) outdir.mkdir(parents=True, exist_ok=True) with Config(dryrun=True, verbose=True): - report = RSTReport() - brainprep_defacing( - t1_file=data.anat, - output_dir=outdir, - keep_intermediate=True, - ) - print(report) - brainprep_group_defacing( - output_dir=outdir, - ) + for modality, modality_data in data.items(): + for subject_data in modality_data.values(): + outputs = brainprep_defacing( + anatomical_file=subject_data.anat, + output_dir=outdir, + keep_intermediate=True, + ) + outputs.deface_anatomical_file.touch(exist_ok=True) + outputs.mask_file.touch(exist_ok=True) + outputs = brainprep_group_defacing( + modality=modality, + output_dir=outdir, + ) # %% @@ -74,18 +88,33 @@ [ [ "brainprep", "subject-level-defacing", - "--t1_file", str(data.anat), + "--anatomical_file", str(subject_data.anat), + "--output-dir", str(outdir), + "--keep-intermediate", + ] + for subject_data in data["T1w"].values() + ] +) +commands.append( + [ + [ + "brainprep", "subject-level-defacing", + "--anatomical_file", str(subject_data.anat), "--output-dir", str(outdir), "--keep-intermediate", ] + for mod in ("T2w", "FLAIR") + for subject_data in data[mod].values() ] ) commands.append( [ [ "brainprep", "group-level-defacing", + "--modality", modality, "--output-dir", str(outdir), ] + for modality in data.keys() ] ) pprint(commands) diff --git a/examples/workflows/plot_fmriprep.py b/examples/workflows/plot_fmriprep.py index c469d11d..4d39ce6d 100644 --- a/examples/workflows/plot_fmriprep.py +++ b/examples/workflows/plot_fmriprep.py @@ -36,6 +36,7 @@ # to actually run each step and generate results on disk. +import shutil from brainprep.workflow import ( brainprep_fmriprep, ) @@ -43,6 +44,8 @@ from brainprep.reporting import RSTReport outdir = Path("/tmp/brainprep-fmriprep") +if outdir.is_dir(): + shutil.rmtree(outdir) outdir.mkdir(parents=True, exist_ok=True) with Config(dryrun=True, verbose=True): report = RSTReport() diff --git a/examples/workflows/plot_quality_assurance.py b/examples/workflows/plot_quality_assurance.py index 058c22b4..a1826a64 100644 --- a/examples/workflows/plot_quality_assurance.py +++ b/examples/workflows/plot_quality_assurance.py @@ -42,7 +42,7 @@ # However, feel free to set the 'dryrun' configuration to False # to actually run each step and generate results on disk. - +import shutil from brainprep.workflow import ( brainprep_quality_assurance, brainprep_group_quality_assurance, @@ -51,6 +51,8 @@ from brainprep.reporting import RSTReport outdir = Path("/tmp/brainprep-qa") +if outdir.is_dir(): + shutil.rmtree(outdir) outdir.mkdir(parents=True, exist_ok=True) with Config(dryrun=True, verbose=True): for subject_data in data.values(): diff --git a/examples/workflows/plot_quasiraw.py b/examples/workflows/plot_quasiraw.py index d121681c..2f72c975 100644 --- a/examples/workflows/plot_quasiraw.py +++ b/examples/workflows/plot_quasiraw.py @@ -4,13 +4,13 @@ Simple example. -Example on how to run the brain parcellation pre-processing using BrainPrep. +Example on how to run the quasiraw pre-processing using BrainPrep. See :ref:`user guide ` for details. Data ---- -Let's first get some anatomical data. +Let's first get some anatomical data: T1w, T2w and FLAIR. """ from pathlib import Path @@ -20,18 +20,20 @@ datadir = Path("/tmp/brainprep-data") datadir.mkdir(parents=True, exist_ok=True) dataset = OpenMSDataset(datadir) -data = Bunch( - sub01=dataset.fetch( - subject="01", - modality="T1w", - dtype="cross_sectional", - ), - sub02=dataset.fetch( - subject="02", - modality="T1w", - dtype="cross_sectional", - ), -) +data = Bunch() +for modality in ("T1w", "T2w", "FLAIR"): + data[modality] = Bunch( + sub01=dataset.fetch( + subject="01", + modality=modality, + dtype="cross_sectional", + ), + sub02=dataset.fetch( + subject="02", + modality=modality, + dtype="cross_sectional", + ), + ) print(data) @@ -44,28 +46,29 @@ # However, feel free to set the 'dryrun' configuration to False # to actually run each step and generate results on disk. - +import shutil from brainprep.workflow import ( brainprep_quasiraw, brainprep_group_quasiraw, ) from brainprep.config import Config -from brainprep.reporting import RSTReport outdir = Path("/tmp/brainprep-quasiraw") +if outdir.is_dir(): + shutil.rmtree(outdir) outdir.mkdir(parents=True, exist_ok=True) with Config(dryrun=True, verbose=True): - for subject_data in data.values(): - report = RSTReport() - brainprep_quasiraw( - anatomical_file=subject_data.anat, + for modality, modality_data in data.items(): + for subject_data in modality_data.values(): + outputs = brainprep_quasiraw( + anatomical_file=subject_data.anat, + output_dir=outdir, + keep_intermediate=True, + ) + outputs = brainprep_group_quasiraw( + modality=modality, output_dir=outdir, - keep_intermediate=True, ) - print(report) - outputs = brainprep_group_quasiraw( - output_dir=outdir, - ) # %% @@ -86,15 +89,19 @@ "--anatomical_file", str(subject_data.anat), "--output-dir", str(outdir), "--keep-intermediate", - ] for subject_data in data.values() + ] + for modality_data in data.values() + for subject_data in modality_data.values() ] ) commands.append( [ [ "brainprep", "group-level-quasiraw", + "--modality", modality, "--output-dir", str(outdir), ] + for modality in data.keys() ] ) pprint(commands) diff --git a/examples/workflows/plot_sbm.py b/examples/workflows/plot_sbm.py index 52619b9d..7027dad8 100644 --- a/examples/workflows/plot_sbm.py +++ b/examples/workflows/plot_sbm.py @@ -44,7 +44,7 @@ # However, feel free to set the 'dryrun' configuration to False # to actually run each step and generate results on disk. - +import shutil from brainprep.workflow import ( brainprep_sbm, brainprep_group_sbm, @@ -54,6 +54,8 @@ from brainprep.reporting import RSTReport outdir = Path("/tmp/brainprep-sbm") +if outdir.is_dir(): + shutil.rmtree(outdir) outdir.mkdir(parents=True, exist_ok=True) with Config(dryrun=True, verbose=True): for subject_data in data.values(): diff --git a/examples/workflows/plot_sulcirec.py b/examples/workflows/plot_sulcirec.py new file mode 100644 index 00000000..6b7949c0 --- /dev/null +++ b/examples/workflows/plot_sulcirec.py @@ -0,0 +1,108 @@ +""" +Sulci Reconstruction +==================== + +Simple example. + +Example on how to run the sulci reconstruction and identification +pre-processing using BrainPrep. +See :ref:`user guide ` for details. + +Data +---- + +Let's first get some anatomical data. +""" + +from pathlib import Path +from brainprep.datasets import IBCDataset + +datadir = Path("/tmp/brainprep-data") +datadir.mkdir(parents=True, exist_ok=True) +dataset = IBCDataset(datadir) +data = dataset.fetch( + subject="01", + modality="func", +) +print(data) + + +# %% +# Analysis +# -------- +# +# Let's now perform the preprocessing using BrainPrep. +# As with many tutorials, we won't execute the code directly here. +# However, feel free to set the 'dryrun' configuration to False +# to actually run each step and generate results on disk. + +import shutil +from brainprep.workflow import ( + brainprep_group_sulcirec, + brainprep_sulcirec, +) +from brainprep.config import Config +from brainprep.reporting import RSTReport + +outdir = Path("/tmp/brainprep-sulcirec") +if outdir.is_dir(): + shutil.rmtree(outdir) +outdir.mkdir(parents=True, exist_ok=True) +with Config(dryrun=True, verbose=True): + report = RSTReport() + brainprep_sulcirec( + t1_file=data.anat, + output_dir=outdir, + keep_intermediate=False, + ) + print(report) + brainprep_group_sulcirec( + output_dir=outdir, + keep_intermediate=False, + ) + + +# %% +# CLI +# --- +# +# Let's now generate the same analysis using the CLI. The goal here is to +# translate the workflow calls into explicit shell commands. +# See :ref:`user guide ` for details. + +from pprint import pprint + +commands = [] +commands.append( + [ + [ + "brainprep", "subject-level-sulcirec", + "--t1-file", str(data.anat), + "--output-dir", str(outdir), + ] + ] +) +commands.append( + [ + [ + "brainprep", "group-level-sulcirec", + "--output-dir", str(outdir), + ], + ] +) +pprint(commands) + + +# %% +# Container +# --------- +# +# Note that the commands generated by the CLI are not limited to being +# displayed for reference; they can also be executed directly within the +# workflow‑dedicated container. By running the commands inside the container, +# you benefit from a controlled runtime context where all necessary +# dependencies, libraries, and configuration files are already available. +# In practice, this means that once the CLI has produced the appropriate +# instructions, you can simply copy and run them inside the container to +# achieve the intended results. You can find the BrainPrep images on Docker +# Hub: `Neurospin Docker Hub `_. diff --git a/examples/workflows/plot_vbm.py b/examples/workflows/plot_vbm.py index dadaa1a5..744d792c 100644 --- a/examples/workflows/plot_vbm.py +++ b/examples/workflows/plot_vbm.py @@ -44,7 +44,7 @@ # However, feel free to set the 'dryrun' configuration to False # to actually run each step and generate results on disk. - +import shutil from brainprep.workflow import ( brainprep_group_vbm, brainprep_longitudinal_vbm, @@ -54,6 +54,8 @@ from brainprep.reporting import RSTReport outdir = Path("/tmp/brainprep-vbm") +if outdir.is_dir(): + shutil.rmtree(outdir) outdir.mkdir(parents=True, exist_ok=True) with Config(dryrun=True, verbose=True): for subject_data in data.values(): diff --git a/pyproject.toml b/pyproject.toml index 1343a0e3..9239f67b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -113,6 +113,8 @@ extend-select = [ "W291", # trailing whitespace "E203", # whitespace before ':' "E501", # line too long (226 > 79 characters) + "F401", # unused-import + "F821", # undefined-name ] ignore = [ "D401", # First line of docstring should be in imperative mood diff --git a/tools/build/build_cli.py b/tools/build/build_cli.py index 894db0ef..6702e4a0 100644 --- a/tools/build/build_cli.py +++ b/tools/build/build_cli.py @@ -25,7 +25,6 @@ def build( working_dir: str | Path, bind_dir: str | Path, - partition: str, freesurfer_license_file: str | Path, dev: bool = False, ) -> None: @@ -33,72 +32,47 @@ def build( Parse available Docker files and generate the associated build instructions (creation and test steps). - Two infrastructures are supported: ``ccc`` and ``slurm``. - To select one, use either ```` or ``:`` as the value - of the ``partition`` parameter. - Parameters ---------- working_dir : str | Path Directory where the generated instructions will be written. bind_dir : str | Path - Directory containing the data to be bound into the Docker environment. - partition : str - Name of the partition to use. Can be provided as ```` or - ``:`` depending on the infrastructure. + Directory containing the data to be bound into the Apptainer + environment. freesurfer_license_file : str | Path Path to the FreeSurfer license file required for container execution. dev : bool If True, overwrite the ``brainprep`` module inside the container image. - Default is False. + Default False. """ cw_dir = Path(__file__).parent.resolve() working_dir = Path(working_dir) - hopla_dir = working_dir / f"v{version}" / "hopla" - home_dir = working_dir / f"v{version}" / "home" + workspace_dir = working_dir / f"v{version}" / "data" + home_dir = workspace_dir / "home" examples_dir = cw_dir.parent.parent / "examples" - for dir_ in (hopla_dir, home_dir): - dir_.mkdir(parents=True, exist_ok=True) - - if ":" in partition: - infra = "ccc" - project_id, partition = partition.split(":") - image_extension = "sif" - else: - infra = "slurm" - project_id = None - image_extension = "tar" - - if infra == "slurm": - build_images_main( - working_dir, - ) + home_dir.mkdir(parents=True, exist_ok=True) + print(f"- Home direcotry: {home_dir}") + + build_images_main( + working_dir, + ) + placeholder = "{workflow}" - if infra == "slurm": - image_parameters = f"--cleanenv --home {home_dir} --bind {bind_dir} " - if dev: - image_parameters += ( - f"--bind {cw_dir.parent.parent / 'brainprep'}:" - "/opt/brainprep/.pixi/envs/default/lib/python3.12/site-packages/" - "brainprep " - ) - elif infra == "ccc" and dev: - image_parameters = ( - f"-v {cw_dir.parent.parent / 'brainprep'}:" + image_parameters = f"--cleanenv --home {home_dir} --bind {bind_dir}" + if dev: + image_parameters += ( + f" --bind {cw_dir.parent.parent / 'brainprep'}:" "/opt/brainprep/.pixi/envs/default/lib/python3.12/site-packages/" - "brainprep " + "brainprep" ) - else: - image_parameters = "" build_tests_main( examples_dir, - infra=infra, image_template=( working_dir / f"v{version}" / placeholder / - f"brainprep-{placeholder}-v{version}.{image_extension}" + f"brainprep-{placeholder}-v{version}.sif" ), save_template=( working_dir / @@ -111,11 +85,8 @@ def build( f"v{version}" / placeholder ), - image_parameters=image_parameters, - hopla_dir=hopla_dir, - partition=partition, freesurfer_license_file=freesurfer_license_file, - project_id=project_id, + image_parameters=image_parameters, ) @@ -136,25 +107,21 @@ def main(): Examples -------- Build test instructions from the example scripts in the ``examples`` - repository directory using the ``slurm`` infrastructure and a given - container image: + repository: python3 containers/build/build_cli.py build-tests \ --examples-dir examples \ - --infra slurm \ - --image-template /tmp/brainprep-{workflow}-v2.0.0.sif \ - --hopla-dir /tmp/hopla + --image-template /tmp/brainprep-{workflow}-v2.0.0.sif - Build image creation instructions in a given folder: + Build image creation instructions: python3 containers/build/build_cli.py build-images \ --working-dir /tmp/build - Build image creation and test instructions in a given folder: + Build image creation and test instructions: python3 containers/build/build_cli.py build \ - --working-dir /tmp/build \ - --bind-dir /my/data/dir + --working-dir /tmp/build """ fire.Fire({ "build-tests": build_tests_main, diff --git a/tools/build/build_images.py b/tools/build/build_images.py index 566e9a77..886f22a2 100644 --- a/tools/build/build_images.py +++ b/tools/build/build_images.py @@ -10,8 +10,6 @@ Provide a command line interface to generate image build instructions. """ -import glob -import os import shutil from pathlib import Path @@ -96,7 +94,10 @@ def main( # Build command script cmds = [ f"cd {dest_dir}", - f"sudo docker build --no-cache --tag brainprep-{name}:{version} .", + ( + "sudo DOCKER_BUILDKIT=1 docker build --no-cache " + f"--tag brainprep-{name}:{version} ." + ), "sudo docker images", ( f"sudo docker save -o brainprep-{name}-v{version}.tar " @@ -104,12 +105,12 @@ def main( ), f"sudo chmod 755 brainprep-{name}-v{version}.tar", ( - f"sudo SINGULARITY_TMPDIR={tmp_dir} " - f"SINGULARITY_CACHEDIR={cache_dir} " - f"singularity build brainprep-{name}-v{version}.sif " + f"sudo APPTAINER_TMPDIR=$PWD/tmp " + f"APPTAINER_CACHEDIR=$PWD/cache " + f"apptainer build brainprep-{name}-v{version}.sif " f"docker-archive://brainprep-{name}-v{version}.tar" ), - f"singularity inspect brainprep-{name}-v{version}.sif", + f"apptainer inspect brainprep-{name}-v{version}.sif", ] # Write commands file diff --git a/tools/build/build_tests.py b/tools/build/build_tests.py index bc491794..63488fb6 100644 --- a/tools/build/build_tests.py +++ b/tools/build/build_tests.py @@ -13,26 +13,19 @@ import copy import io -import json import runpy import shutil import sys -import tomllib -from datetime import datetime from pathlib import Path def main( examples_dir: str | Path, - infra: str, image_template: str, - hopla_dir: str | Path, - partition: str, freesurfer_license_file: str | Path, - project_id: str | None = None, - image_parameters: str | None = None, root_template: str | None = None, save_template: str | None = None, + image_parameters: str | None = None, ) -> None: """ Execute examples scripts using ``hoplacli``. @@ -47,34 +40,28 @@ def main( ---------- examples_dir : str | Path Directory containing example Python scripts. - infra : str - Infrastructure identifier. image_template : str Path to the container or image file referenced in the generated config where `{workflow}` acts as a placeholder. The calling code replaces `{workflow}` with the name of the image being processed. Using a template allows the script to dynamically generate commands for different images without duplicating code. - hopla_dir: str | Path - Path to the hopla working directory. - partition : str - Name of the partition to use. freesurfer_license_file : str | Path Path to the FreesurFer license file. - project_id : str | None - Name of the project identifier. Default None. - image_parameters: str | None - Additional parameters passed to the container execution command. - Default None. root_template : str | None Path to the working directory where `{workflow}` acts as a placeholder. The calling code replaces `{workflow}` with the name of the image being processed. Defaults are directly defined in the - examples: ``datadir`` and ``outdir``. Default None. - save_template: str | None + examples: ``datadir`` and ``outdir``. + Default None. + save_template : str | None Path to the file where the testing commands are saved where `{workflow}` acts as a placeholder. The calling code replaces - `{workflow}` with the name of the image being processed. Default None. + `{workflow}` with the name of the image being processed. + Default None. + image_parameters: str | None + Additional parameters passed to the Apptainer container. + Default None. Raises ------ @@ -92,20 +79,17 @@ def main( """ print(banner) - # Get configuration templates - cw_dir = Path(__file__).parent.resolve() - config_dir = cw_dir.parent / "resources" - config_file = config_dir / "hopla_config.toml" - config_template = config_file.read_text() - workflow_resource_file = config_dir / "workflows_config.toml" - with workflow_resource_file.open("rb") as of: - workflow_resource = tomllib.load(of) - image_parameters = image_parameters or "" + # Update container parameters + image_parameters += ( + f" --bind {freesurfer_license_file}:/opt/freesurfer/license.txt" + ) # Scan example scripts + cw_dir = Path(__file__).parent.resolve() examples_dir = Path(examples_dir) script_paths = examples_dir.glob("*/*.py") - hoplacli_commands = [] + image_parameters = image_parameters or "" + commands = [] start = 0 for script_file in script_paths: @@ -121,14 +105,13 @@ def main( sys.stdout = io.StringIO() env = runpy.run_path(str(script_file)) sys.stdout = original_stdout - commands = env.get("commands", []) - if len(commands) == 0: + examples_commands = env.get("commands", []) + if len(examples_commands) == 0: print("- No command") continue - # Prepapre commands to execute code with hoplacli - print(f"- Execution: {len(commands)} steps") - confs = workflow_resource[workflow_name] + # Prepare commands to execute + print(f"- Execution: {len(examples_commands)} step(s)") if root_template is not None: datadir_orig = Path(env["datadir"]) outdir_orig = Path(env["outdir"]) @@ -137,27 +120,38 @@ def main( workflow="data", ) ) - outdir = Path( + scriptdir = Path( str(root_template).format( workflow=workflow_name, ) ) + outdir = datadir / "derivatives" print(f"- Copy data: {datadir_orig} -> {datadir}") shutil.copytree(datadir_orig, datadir, dirs_exist_ok=True) else: datadir = Path(env["datadir"]) + scriptdir = datadir outdir = Path(env["outdir"]) + print(f"- Data directory: {datadir}") + print(f"- Script directory: {scriptdir}") + print(f"- Output directory: {outdir}") outdir.mkdir(parents=True, exist_ok=True) - for idx, step_commands in enumerate(commands, start=1): - config_path = outdir / f"config_step{idx}.toml" - image_parameters_ = image_parameters + for idx, step_commands in enumerate(examples_commands, start=1): + run_file = scriptdir / f"run_{idx}.sh" + image_file = str(image_template).format( + workflow=workflow_name, + ) # Format commands step_commands = [ [*cmd, "--no-color"] for cmd in step_commands ] - step_commands_str = json.dumps(step_commands, indent=2) + run_cmd = f"apptainer run {image_parameters} {image_file}" + step_commands_str = "\n".join([ + f"{run_cmd} {' '.join(cmd_)} &" + for cmd_ in step_commands + ]) if root_template is not None: step_commands_str = step_commands_str.replace( str(datadir_orig), @@ -167,52 +161,20 @@ def main( str(outdir_orig), str(datadir), ) + print(f"- Commands:\n {step_commands_str}") - # Fill template - image_file = str(image_template).format( - workflow=name.replace("plot_", ""), - ) - worflow_type = step_commands[0][1].split("-")[0] - selected_conf = confs.get(worflow_type, confs["default"]) - if selected_conf.get("freesurfer", False): - if infra == "ccc": - image_parameters_ += ( - f"-v {freesurfer_license_file}:" - "/opt/freesurfer/license.txt " - ) - else: - image_parameters_ += ( - f"--bind {freesurfer_license_file}:" - "/opt/freesurfer/license.txt " - ) - config_str = config_template.format( - name=f"{script_file.stem}-step{idx}", - operator="deamon", - date=str(datetime.now().date()), - commands=step_commands_str, - parameters=image_parameters_, - cluster=infra, - partition=partition, - n_cpus=selected_conf["n_cpus"], - memory=selected_conf["memory"], - image_file=image_file, - project_id=project_id, - backend=selected_conf.get("backend", "flux"), - hopla_dir=hopla_dir, - ) - - # Write config file - with config_path.open("w") as of: - of.write(config_str) + # Write commands to file + bash_str = "#!/bin/bash\n\n" + bash_str += step_commands_str + bash_str += "\n\nwait" + with run_file.open("w") as of: + of.write(bash_str) - # Execute hoplacli command - hopla_cmd = [ - "hoplacli", - "--config", str(config_path), - "--njobs", "10", - ] - hoplacli_commands.append(hopla_cmd) - print(f"- Command: {''.join(hopla_cmd)}") + # Execute commands + commands.append( + f". {run_file}" + ) + print(f"- Command: {commands[-1]}") # Save generated testing commands if save_template is not None: @@ -225,11 +187,9 @@ def main( save_file.touch() with save_file.open("a") as of: of.write( - "\n".join( - " ".join(cmd) for cmd in hoplacli_commands[start:] - ) + "\n".join(commands[start:]) ) - start = len(hoplacli_commands) + start = len(commands) print(f"- Generated build instructions: {save_file}") print( @@ -254,7 +214,7 @@ def main( def merge( defaults: dict, - overrides: dict + overrides: dict, ) -> dict: """ Recursively merge two dictionaries, applying overrides to defaults. diff --git a/tools/resources/workflows_config.toml b/tools/resources/workflows_config.toml index 354ca8ad..1159d17e 100644 --- a/tools/resources/workflows_config.toml +++ b/tools/resources/workflows_config.toml @@ -1,50 +1,40 @@ -[brainprep.workflow] -subject-level-qa = "--image-files {!T1ws},{!T2ws},{!FLAIRs},{!dwis},{!bolds} --output-dir {outdir}" -group-level-qa = "--modalities T1w,T1w,FLAIR,dwi,bold --output-dir {outdir}" -subject-level-defacing = "--t1_file {T1w} --output-dir {outdir}" -group-level-defacing = "--output-dir {outdir}" -subject-level-quasiraw = "--anatomical_file {T1w} --output-dir {outdir}" -group-level-quasiraw = "--output-dir {outdir}" -subject-level-sbm = "--t1_file {T1w} --output-dir {outdir}" -longitudinal-sbm = "--t1_files {T1ws} --model 1 --output-dir {outdir}" -group-level-sbm = "--output-dir {outdir}" -subject-level-vbm = "--t1_file {T1w} --output-dir {outdir}" -longitudinal-vbm = "--t1_files {T1ws} --model 1 --output-dir {outdir}" -group-level-vbm = "--output-dir {outdir}" -subject-level-fmriprep = "--t1-file {T1w} --func-files {bolds} --freesurfer-dir {fsdir} --output-dir {outdir}" -group-level-fmriprep = "--output-dir {outdir}" - [brainprep.mapping] +ALL = [ + "subject-level-qa --image-files {mod} --output-dir {outdir}", + "group-level-qa --modalities {mod_names} --output-dir {outdir}", +] T1w = [ - "subject-level-qa", - "group-level-qa", - "subject-level-defacing", - "group-level-defacing", - "subject-level-quasiraw", - "group-level-quasiraw", - "subject-level-sbm", - "longitudinal-sbm", - "group-level-sbm", - "subject-level-vbm", - "longitudinal-vbm", - "group-level-vbm"] + "subject-level-defacing --anatomical_file {T1w} --output-dir {outdir}", + "group-level-defacing --modality T1w --output-dir {outdir}", + "subject-level-quasiraw --anatomical_file {T1w} --output-dir {outdir}", + "group-level-quasiraw --modality T1w --output-dir {outdir}", + "subject-level-sbm --t1_file {T1w} --output-dir {outdir}", + "longitudinal-sbm --t1_files {T1ws} --model 1 --output-dir {outdir}", + "group-level-sbm --output-dir {outdir}", + "subject-level-vbm --t1_file {T1w} --output-dir {outdir}", + "longitudinal-vbm --t1_files {T1ws} --model 1 --output-dir {outdir}", + "group-level-vbm --output-dir {outdir}", + "subject-level-sulcirec --t1-file {T1w} --output-dir {outdir}", + "group-level-sulcirec --output-dir {outdir}", +] T2w = [ - "subject-level-qa", - "group-level-qa", - "subject-level-quasiraw", - "group-level-quasiraw", + "subject-level-defacing --anatomical_file {T2w} --output-dir {outdir}", + "group-level-defacing --modality T2w --output-dir {outdir}", + "subject-level-quasiraw --anatomical_file {T2w} --output-dir {outdir}", + "group-level-quasiraw --modality T2w --output-dir {outdir}", ] FLAIR = [ - "subject-level-qa", - "group-level-qa", - "subject-level-quasiraw", - "group-level-quasiraw", + "subject-level-defacing --anatomical_file {FLAIR} --output-dir {outdir}", + "group-level-defacing --modality FLAIR --output-dir {outdir}", + "subject-level-quasiraw --anatomical_file {FLAIR} --output-dir {outdir}", + "group-level-quasiraw --modality FLAIR --output-dir {outdir}", +] +BOLD = [ + "subject-level-fmriprep --t1-file {T1w} --func-files {bolds} --freesurfer-dir {fsdir} --output-dir {outdir}", + "group-level-fmriprep --output-dir {outdir}", ] -bold = [ - "subject-level-qa", - "group-level-qa", - "subject-level-fmriprep", - "group-level-fmriprep", +DWI = [ + "subject-level-dmriprep --t1-file {T1w} --dwi-files {dwis} --output-dir {outdir}", ] [sbm.default] @@ -64,6 +54,10 @@ freesurfer = true n_cpus = 1 memory = 5 +[sulcirec.default] +n_cpus = 1 +memory = 1 + [fmriprep.default] n_cpus = 1 memory = 10 @@ -94,3 +88,7 @@ backend = "oneshot" [vbm.group] n_cpus = 1 memory = 5 + +[dmriprep.default] +n_cpus = 1 +memory = 32 diff --git a/tools/scaling/scaling_cli.py b/tools/scaling/scaling_cli.py index 525e8156..95010885 100644 --- a/tools/scaling/scaling_cli.py +++ b/tools/scaling/scaling_cli.py @@ -6,7 +6,8 @@ # for details. ########################################################################## -""" Provide a command line interface. +""" +Provide a command line interface to generate HOPLA configuration files. """ import hashlib @@ -48,24 +49,29 @@ def extract_braced_parameters( template: str, ) -> list[str]: """ - Extract parameter names enclosed in curly braces excluding 'outdir' and - 'description'. + Extract parameter names from a command-line template. + + This function scans a given command-line template string and identifies all + parameter placeholders enclosed in curly braces, except for 'mod_names', + 'outdir' and 'fsdir'. Parameters ---------- template : str - A command-line template containing placeholders like {T1w}. + A command-line template containing placeholders in the format + {param_name}. Example: "command -i {input} -o {output}" Returns ------- - list of str - The parameter names found inside curly braces, in order of appearance. + list[str] + A list of parameter names found in the template. + Example: ["input", "output"] """ params = re.findall(r"{([^}]+)}", template) return [ param for param in params - if param != "outdir" + if param not in ("mod_names", "outdir", "fsdir") ] @@ -74,19 +80,24 @@ def hash_file( chunk_size: int = 8192, ) -> str: """ - Compute a SHA-256 hash of a file in streaming mode. + Compute the SHA-256 hash of a file in streaming mode. + + This function reads a file in chunks to compute its SHA-256 hash, making + it suitable for large files that cannot be loaded entirely into memory. Parameters ---------- path : Path - Path to the file. + The path to the file for which the SHA-256 hash is to be computed. chunk_size : int - Size of chunks to read at a time. + The size of each chunk to read from the file at a time, in bytes. + This parameter controls the trade-off between memory usage and I/O + efficiency. Returns ------- str - Hexadecimal SHA-256 hash. + The hexadecimal representation of the SHA-256 hash of the file. """ sha = hashlib.sha256() with path.open("rb") as of: @@ -102,12 +113,12 @@ def parse_bids( """ Parse a BIDS dataset and collect paths to common MRI modalities. - This function walks through a BIDS-organized directory structure and - extracts the file paths for the most frequently used MRI modalities: - T1-weighted (T1w), T2-weighted (T2w), FLAIR, diffusion-weighted imaging - (DWI), and functional BOLD fMRI. It supports both compressed and - uncompressed NIfTI files (`.nii` and `.nii.gz`) and handles datasets - where some modalities or subdirectories may be missing. + This function traverses a BIDS-organized directory structure to extract + file paths for frequently used MRI modalities: T1-weighted (T1w), + T2-weighted (T2w), FLAIR, diffusion-weighted imaging (DWI), and functional + BOLD fMRI. It supports both compressed and uncompressed NIfTI files + (`.nii` and `.nii.gz`) and handles datasets where some modalities or + subdirectories may be missing. The function assumes a standard BIDS layout: @@ -125,17 +136,17 @@ def parse_bids( Parameters ---------- root : str or Path - Path to a rawdata BIDS dataset. + Path to the root directory of a rawdata BIDS dataset. with_hash : bool - Compute a SHA-256 hash of each parsed file. + If True, compute a SHA-256 hash of each parsed file. Default True. Returns ------- data : dict[str, pd.DataFrame] - Dictionary mapping each modality name (``"T1w"``, ``"T2w"``, - ``"FLAIR"``, ``"dwi"``, ``"bold"``) to a DataFrame containing one row - per discovered file for that modality. Each table includes: + Dictionary mapping each modality name ("T1w", "T2w", "FLAIR", "dwi", + "bold") to a DataFrame containing one row per discovered file for + that modality. Each DataFrame includes: - ``subject`` : str Subject identifier without the ``sub-`` prefix. @@ -193,9 +204,11 @@ def parse_bids( ), } record.setdefault(modality, []).append(row) - print(f"- modalities: {list(record.keys())}") - return {key: pd.DataFrame(val) for key, val in record.items()} + return { + key: pd.DataFrame(val) + for key, val in record.items() + } def organize_bids_tab( @@ -203,18 +216,18 @@ def organize_bids_tab( with_hash: bool = True, ) -> dict[str, pd.DataFrame]: """ - Organize a pre-parsed BIDS dataset . + Organize a pre-parsed BIDS dataset. - This function walks through a BIDS-parsed table and extracts the file + This function processes a pre-parsed BIDS dataset table and extracts file paths for the most frequently used MRI modalities: T1-weighted (T1w), T2-weighted (T2w), FLAIR, diffusion-weighted imaging (DWI), and functional BOLD fMRI. Parameters ---------- - tab_file : str or Path - Path to a pre-parsed rawdata BIDS dataset (as a TSV file). The table - includes: + tab_file : str | Path + Path to a pre-parsed rawdata BIDS dataset in TSV format. The table + should include the followig columns: - ``sub`` : str Subject identifier without the ``sub-`` prefix. @@ -227,15 +240,15 @@ def organize_bids_tab( - ``md5sum`` : str MD5 hash of the file. with_hash : bool - Collect a MD5 hash of each pre-parsed file. + If True, collect an MD5 hash of each pre-parsed file. Default True. Returns ------- data : dict[str, pd.DataFrame] - Dictionary mapping each modality name (``"T1w"``, ``"T2w"``, - ``"FLAIR"``, ``"dwi"``, ``"bold"``) to a DataFrame containing one row - per discovered file for that modality. Each table includes: + Dictionary mapping each modality name ("T1w", "T2w", "FLAIR", "dwi", + "bold") to a DataFrame containing one row per discovered file for + that modality. Each DataFrame includes: - ``subject`` : str Subject identifier without the ``sub-`` prefix. @@ -261,6 +274,7 @@ def organize_bids_tab( print(banner) df = pd.read_csv(tab_file, sep="\t", dtype=str) + rawdata_path = str(Path(tab_file).parent) record = {} for _, row in df.iterrows(): @@ -268,48 +282,99 @@ def organize_bids_tab( row = { "subject": row["sub"], "session": row["ses"], - modality: row["path"], + modality: row["path"].replace("./", f"{rawdata_path}/"), f"{modality}_md5_hash": ( row["md5sum"] if with_hash else None ), } record.setdefault(modality, []).append(row) - print(f"- modalities: {list(record.keys())}") return {key: pd.DataFrame(val) for key, val in record.items()} +def concatenate_modalities( + dfs: dict[str, pd.DataFrame], + ) -> pd.DataFrame: + """ + Concatenate modalities for each subject. + + This function takes a dictionary of DataFrames, each containing 'subject', + 'session', and '' columns. It concatenates the 'modality' values + for each subject, counts the number of modalities, and returns a DataFrame + with the concatenated modalities and their counts. + + Parameters + ---------- + dfs : dict[str, pd.DataFrame] + A dictionary where keys are modality names and values are DataFrames + containing 'subject', 'session', and '' columns. + + Returns + ------- + pd.DataFrame + A DataFrame with columns 'subject', 'mod', and 'count'. The 'mod' + column contains concatenated modality values for each subject, and + the 'count' column contains the number of modalities for each subject. + """ + concatenated_dfs = [ + df.assign(mod=df[mod]) + for mod, df in dfs.items() + + ] + + combined_df = pd.concat(concatenated_dfs) + + all_df = combined_df.groupby(["subject"]).agg({ + "mod": lambda x: ", ".join(x) + }).reset_index() + + all_df["count"] = all_df["mod"].str.count(",") + all_df["count"] += 1 + + return all_df + + def organize_longitudinal( data: dict[str, pd.DataFrame], htype: str = "sha256", ) -> dict[str, pd.DataFrame]: """ - Organize BIDS modality tables into one longitudinal table per modality, - expanding multiple files into separate columns (e.g., T1w-1, T1w-2). + Organize BIDS modality tables into one longitudinal table per modality. + + This function reorganizes the input dictionary of modality tables into a + longitudinal format, expanding multiple files into separate columns + (e.g., T1w-1, T1w-2). Parameters ---------- data : dict[str, pd.DataFrame] - Dictionary where keys are modality names (e.g., "T1w", "dwi", "bold") + Dictionary where keys are modality names (e.g., "T1w", "T2w", "FLAIR") and values are DataFrames containing: - - "subject" - - "session" - - "" - - "__hash" + - "subject": Subject identifier without the ``sub-`` prefix. + - "session": Session identifier without the ``ses-`` prefix. + - "": File path to the corresponding NIfTI image. + - "__hash": Hash of the file. htype : str - Hash type. Default 'sha256'. + The type of hash used. + Default 'sha256'. Returns ------- data : dict[str, pd.DataFrame] - One DataFrame per modality, with one row per subject/session. - If multiple files exist for a modality, they are expanded into - columns named "-1", "-2", ... and - "__hash-1", "__hash-2", ... + A dictionary with one DataFrame per modality, where each DataFrame has + one row per subject/session. If multiple files exist for a modality, + they are expanded into columns named "-1", "-2", + ... and "__hash-1", "__hash-2", ... + + Raises + ------ + ValueError + If the input DataFrame does not contain one row per unique + subject/session pair. """ banner = r""" +----------------------------------+ - | Organize longitudianl data... | + | Organize longitudinal data... | +----------------------------------+ """ print(banner) @@ -318,53 +383,63 @@ def organize_longitudinal( for modality, df in data.items(): df = df.sort_values(["subject", "session"]).reset_index(drop=True) - df["idx"] = df.groupby(["subject", "session"]).cumcount() + 1 - files_wide = df.pivot_table( - index=["subject", "session"], - columns="idx", - values=modality, - aggfunc="first" - ) - hashes_wide = df.pivot_table( - index=["subject", "session"], - columns="idx", + try: + files_wide = df.pivot( + index="subject", + columns="session", + values=modality, + ) + except Exception as exc: + print(f"- {modality}:") + df_ = ( + df.groupby(["subject", "session"]) + .size() + .reset_index( + name="total" + ) + ) + print(df_[df_["total"] > 1]) + raise ValueError( + "Can't pivot. Expect one row per subject/session pair. See " + "descrition above." + ) from exc + hashes_wide = df.pivot( + index="subject", + columns="session", values=f"{modality}_{htype}_hash", - aggfunc="first" ) - files_wide.columns = [ - f"{modality}-{idx}" - for idx in files_wide.columns + f"{modality}-{ses}" + for ses in files_wide.columns ] hashes_wide.columns = [ - f"{modality}_{htype}_hash-{idx}" - for idx in hashes_wide.columns + f"{modality}_{htype}_hash-{ses}" + for ses in hashes_wide.columns ] - merged = pd.concat([files_wide, hashes_wide], axis=1).reset_index() merged = merged[[ "subject", *sorted([ name for name in merged.columns - if name not in ("subject", "session") + if name != "subject" ]) ]] record[modality] = merged - print(f"- longitudinal dataset: {1 if len(record) > 0 else 0}") - return record def collect_config( infra: str, + modality: str, bind_dir: str | Path, config_file: str | Path, dfs: dict[str, pd.DataFrame], long_dfs: dict[str, pd.DataFrame], - worflow_id: str, + timepoints: list[str], + workflow_id: str, workflow_parameters: str, workflow_resource: dict, image_dir: str | Path, @@ -375,33 +450,48 @@ def collect_config( freesurfer_license_file: str | Path, ) -> None: """ + Generate a HOPLA configuration file. + + This function processes modality data for generating a configuration file, + handling different types of data (longitudinal, multi-target, + single-target). It prepares the data by selecting relevant columns, + cleaning the DataFrames, and merging them based on specified parameters. + The function then formats workflow parameters and generates the final + configuration file. + Parameters ---------- infra : str Infrastructure identifier. + modality : str + The current modality being processed. bind_dir : str | Path Directory containing the data to be bound into the Docker or Apptainer environment. config_file : str | Path Template configuration file. dfs : dict[str, pd.DataFrame] - Dictionary mapping each modality name (``"T1w"``, ``"T2w"``, - ``"FLAIR"``, ``"dwi"``, ``"bold"``) to a DataFrame containing one row - per discovered file for that modality. + A dictionary mapping each modality name ("T1w", "T2w", "FLAIR", "dwi", + "bold") to a DataFrame containing one row per discovered file for that + modality. long_dfs : dict[str, pd.DataFrame] - One DataFrame per modality, with one row per subject/session. - If multiple files exist for a modality, they are expanded into - columns named "-1", "-2". - worflow_id : str - The workflow dcalred name in brainprep CLI. + A dictionary with one DataFrame per modality, where each DataFrame has + one row per subject/session. If multiple files exist for a modality, + they are expanded into columns named "-1", + "-2", ... + timepoints : list[str] + The timepoints to consider in the longitudinal analysis. + Default None. + workflow_id : str + The workflow declared name in brainprep CLI. workflow_parameters : str A command-line template containing placeholders like {T1w}. workflow_resource : dict Workflow configurations. image_dir: str | Path - Path to the apptainer or docker images location. + Path to the Apptainer or Docker images location. image_version: str - The image version. + The Apptainer or Docker image version. working_dir : str | Path Directory where the generated instructions will be written. partition : str @@ -409,7 +499,7 @@ def collect_config( project_id : str Name of the project to use. freesurfer_license_file : str | Path - Path to the FreeSurfer license file required for container execution + Path to the FreeSurfer license file required for container execution. """ banner = r""" +----------------------------------+ @@ -418,60 +508,110 @@ def collect_config( """ print(banner) - workflow_name = worflow_id.split("-")[-1] + workflow_name = workflow_id.split("-")[-1] if workflow_name == "qa": workflow_name = "quality_assurance" - workflow_type = worflow_id.split("-")[0] + workflow_type = workflow_id.split("-")[0] + is_longitudinal = (workflow_type == "longitudinal") + print(f"- modality: {modality}") print(f"- name: {workflow_name}") print(f"- type: {workflow_type}") print(f"- parameters: {workflow_parameters}") output_dir = ( working_dir / - f"{workflow_name}_{workflow_type}" + f"{workflow_name}_{workflow_type}_{modality}" ) params = extract_braced_parameters(workflow_parameters) - print(f"- varaibles: {params}") + print(f"- variables: {params}") record = [] for key in params: - is_optional = key[0] == "!" - is_missing = True - key = key[1:] if key[0] == "!" else key - if key.endswith("s") and key[:-1] in long_dfs: - record.append(long_dfs[key[:-1]].dropna()) - multi_params = [ - f"{{{key_}}}" - for key_ in sorted( - set(record[-1].columns) - {"subject", "session"} - ) + is_multi_targets = key[-1] == "s" + data = ( + long_dfs + if is_longitudinal + else dfs + ) + key_ = ( + key[:-1] + if is_longitudinal or is_multi_targets + else key + ) + if key_ not in data: + print(f"- missing data: {key_}") + print(f"- available data: {data.keys()}") + return + mod_df = data[key_] + if is_longitudinal: + col_mod_names = [ + f"{key_}-{tp}" + for tp in timepoints + ] + merge_on = [ + "subject", + ] + workflow_parameters = workflow_parameters.format_map( + SafeDict({ + key: ",".join([ + f"{{{name}}}" + for name in col_mod_names + ]) + }) + ) + elif is_multi_targets: + col_mod_names = [ + key_ ] - workflow_parameters = workflow_parameters.replace( - f"{{!{key}}}" if is_optional else f"{{{key}}}", - ",".join(multi_params), + merge_on = [ + "subject", + "session", + ] + workflow_parameters = workflow_parameters.format_map( + SafeDict({ + key: f"{{{key_}}}", + }) ) - is_missing = False - elif key in dfs: - record.append(dfs[key]) - is_missing = False - if is_optional and is_missing: - workflow_parameters = workflow_parameters.replace( - f",{{!{key}}}", - "", + mod_df = grouped_df = ( + mod_df.groupby(merge_on)[key_] + .agg(lambda x: ", ".join(x)) + .reset_index() ) - is_missing = False - if is_missing: - print(f"- missing data: {params}") - return + else: + col_mod_names = [ + key_ + ] + merge_on = ( + ["subject", "session"] + if modality != "ALL" + else ["subject"] + ) + + mod_df = mod_df[ + [ + *merge_on, + *col_mod_names, + ] + ] + mod_df = mod_df.dropna() + record.append(mod_df) df = ( - reduce(lambda left, right: pd.merge( - left, right, on="subject", how="inner" - ), record) + reduce( + lambda left, right: pd.merge( + left, right, on=merge_on, how="inner" + ), + record, + ) if len(params) > 0 else None ) + workflow_parameters = workflow_parameters.format_map( - SafeDict({"outdir": output_dir / "data"}) + SafeDict({ + "mod_names": ",".join(set(dfs.keys()) - {"mod"}), + "outdir": working_dir / "derivatives", + "fsdir": working_dir / "derivatives" / "sbm" + }) ) print(f"- edited parameters: {workflow_parameters}") @@ -486,6 +626,7 @@ def collect_config( if infra == "slurm": image_parameters = ( f"--cleanenv --home {home_dir} --bind {bind_dir} " + f"--bind {output_dir} " ) else: image_parameters = "" @@ -511,9 +652,9 @@ def collect_config( config_template = config_file.read_text() config_str = config_template.format( name=workflow_name, - operator="TO UPDATE", + operator="brainprepdesk support team", date=str(datetime.now().date()), - commands=f'"brainprep {workflow_name} {workflow_parameters}"', + commands=f'"brainprep {workflow_id} {workflow_parameters}"', parameters=image_parameters, cluster=infra, partition=partition, @@ -555,13 +696,19 @@ def scan_configs( working_dir: str | Path, partition: str, freesurfer_license_file: str | Path, + timepoints: list[str] | None = None, with_hash: bool = False, + with_longitudinal: bool = True, allowed_workflows: list[str] | None = None, ) -> None: """ - Two infrastructures are supported: ``ccc`` and ``slurm``. - To select one, use either ```` or ``:`` as the value - of the ``partition`` parameter. + Generate HOPLA configuration files for supported infrastructures. + + This function generates configuration files for the specified + infrastructure, using either ```` or ``:`` as the + value of the ``partition`` parameter to select the infrastructure. + It parses a BIDS dataset, organizes the data, and generates configuration + files for specified workflows. Parameters ---------- @@ -577,13 +724,20 @@ def scan_configs( Name of the partition to use. Can be provided as ```` or ``:`` depending on the infrastructure. freesurfer_license_file : str | Path - Path to the FreeSurfer license file required for container execution + Path to the FreeSurfer license file required for container executions. + timepoints : list[str] + The timepoints to consider in the longitudinal analysis. + Default None. with_hash : bool - Compute a SHA-256 hash of each parsed file. + If True, compute a SHA-256 hash of each parsed file. Dafault False. + with_longitudinal : bool + If True, configure longitudinal workflows. + Default True. allowed_workflows : list[str] | None Optionally specify a subset of workflows to consider. If None, all available workflows will be used. + Default None. """ root = Path(root) image_dir = Path(image_dir) @@ -613,9 +767,9 @@ def scan_configs( # Parse root cache_files = list(root.glob("rawdata_v-*.tsv")) + selected = None if len(cache_files) == 0: - print("No cache files found. Parsing data.") - selected = None + print("No cache file. Parsing data.") else: print("Multiple cache files found:") for idx, path in enumerate(cache_files, 1): @@ -628,54 +782,80 @@ def scan_configs( print(f"Selected: {selected}") else: print("No valid selection. Force parsing.") - selected = None if selected is None: - dfs = parse_bids(root=root, with_hash=with_hash) + dfs = parse_bids( + root=root, + with_hash=with_hash, + ) htype = "sha256" else: - dfs = organize_bids_tab(tab_file=selected, with_hash=with_hash) + dfs = organize_bids_tab( + tab_file=selected, + with_hash=with_hash, + ) htype = "md5" - long_dfs = organize_longitudinal(dfs, htype=htype) + dfs["mod"] = concatenate_modalities(dfs) + for mod, mod_df in dfs.items(): + mod_df.to_csv( + working_dir / f"data_{mod}.tsv", + sep="\t", + index=False, + ) + print(f"- {mod}:") + print(mod_df) + + if with_longitudinal: + filtered_dfs = { + key: val + for key, val in dfs.items() + if key in set(dfs.keys()) - {"dwi", "bold", "mod"} + } + long_dfs = organize_longitudinal(filtered_dfs, htype=htype) + else: + long_dfs = {} + for mod, mod_df in long_dfs.items(): + mod_df.to_csv( + working_dir / f"longdata_{mod}.tsv", + sep="\t", + index=False, + ) + print(f"- {mod}:") + print(mod_df) # Scan workflows - workflows = workflow_resource["brainprep"]["workflow"] workflow_mapping = workflow_resource["brainprep"]["mapping"] - known_workflows = [ - name - for key in dfs - for name in workflow_mapping.get(key, []) - ] - if allowed_workflows is None: - allowed_workflows = known_workflows - else: - if isinstance(allowed_workflows, str): - allowed_workflows = allowed_workflows.split(",") - unknown = set(allowed_workflows) - set(known_workflows) - if unknown: - raise ValueError( - f"Unknown workflow IDs: {', '.join(sorted(unknown))}. " - f"Valid workflows are: {', '.join(sorted(known_workflows))}" + for mod, workflow_list in workflow_mapping.items(): + for workflow_pattern in workflow_list: + workflow_id, workflow_parameters = workflow_pattern.split(" ", 1) + if ( + allowed_workflows is not None and + workflow_id not in allowed_workflows + ): + print(f"\n-- skip: {workflow_id} --") + continue + if timepoints is None and "longitudinal" in workflow_id: + print(f"\n-- skip: {workflow_id} --") + print(timepoints) + print(f"|-> need timepoints specification --") + continue + collect_config( + infra, + mod, + root.parent, + config_file, + dfs, + long_dfs, + timepoints, + workflow_id, + workflow_parameters, + workflow_resource, + image_dir, + image_version, + working_dir, + partition, + project_id, + freesurfer_license_file, ) - for worflow_id, workflow_parameters in workflows.items(): - if worflow_id not in allowed_workflows: - print(f"\n-- skip: {worflow_id} --") - continue - collect_config( - infra, - root.parent, - config_file, - dfs, - long_dfs, - worflow_id, - workflow_parameters, - workflow_resource, - image_dir, - image_version, - working_dir, - partition, - project_id, - freesurfer_license_file, - ) def main():