diff --git a/.github/codespellignore.txt b/.github/codespellignore.txt new file mode 100644 index 00000000..2d554823 --- /dev/null +++ b/.github/codespellignore.txt @@ -0,0 +1 @@ +licences diff --git a/.github/run_examples.py b/.github/run_examples.py deleted file mode 100644 index bf71b5f5..00000000 --- a/.github/run_examples.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- coding: utf-8 -*- -########################################################################## -# NSAp - Copyright (C) CEA, 2021 -# 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. -########################################################################## - -import os -import subprocess -from joblib import Parallel, delayed - - -currentdir = os.path.dirname(__file__) -examplesdir = os.path.join(currentdir, os.pardir, "examples") - -example_files = [] -for root, dirs, files in os.walk(examplesdir): - for basename in files: - if basename.endswith(".py"): - example_files.append(os.path.abspath( - os.path.join(root, basename))) -print("'{0}' examples found!".format(len(example_files))) - -def runner(path): - print("-- ", path) - cmd = ["python3", path] - env = os.environ - env["MPLBACKEND"] = "Agg" - subprocess.check_call(cmd, env=env) - -Parallel(n_jobs=1, verbose=50)(delayed(runner)(path) for path in example_files) diff --git a/.github/run_unitests.py b/.github/run_unitests.py deleted file mode 100644 index 57f03064..00000000 --- a/.github/run_unitests.py +++ /dev/null @@ -1,31 +0,0 @@ -# -*- coding: utf-8 -*- -########################################################################## -# NSAp - Copyright (C) CEA, 2021 -# 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. -########################################################################## - -import os -import subprocess -from joblib import Parallel, delayed - -currentdir = os.path.dirname(__file__) -testsdir = os.path.join(currentdir, os.pardir, "brainprep", "tests") - -test_files = [] -for root, dirs, files in os.walk(testsdir): - for basename in files: - if basename.endswith(".py"): - test_files.append(os.path.abspath( - os.path.join(root, basename))) -print("'{0}' tests found!".format(len(test_files))) - -def runner(path): - print("-- ", path) - cmd = ["python3", path] - env = os.environ - subprocess.check_call(cmd, env=env) - -Parallel(n_jobs=1, verbose=50)(delayed(runner)(path) for path in test_files) diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml new file mode 100644 index 00000000..b6fe1ead --- /dev/null +++ b/.github/workflows/codespell.yml @@ -0,0 +1,48 @@ +--- +# Workflow to check common misspellings in text files. +# +# To run this check locally from the repository folder: +# +# .. code-block:: bash +# +# $ codespell --toml pyproject.toml brainprep examples +### +name: "SpellLinter[codespell]" + +on: + push: + branches: + - "master" + - "main" + - "dev" + pull_request: + branches: + - "*" + workflow_dispatch: + +jobs: + codespell: + + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + python-version: [3.12] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Codespell + uses: codespell-project/actions-codespell@v2 + with: + path: examples,brainprep + builtin: clear,rare,en-GB_to_en-US + ignore_words_file: ./.github/codespellignore.txt + skip: ./.git,*.bib,./brainprep/resources/*,./AUTHORS.rst diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index fe21d4cb..dfeef89c 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -1,105 +1,104 @@ -name: "DOCUMENTATION" +# Workflow to build doc, upload it as artifact, and deploy it for the +# master, main and dev branches. +# +# To run this check locally from the `doc` folder: +# +# .. code-block:: bash +# +# $ sphinxdoc -v 2 -p $MODULE_DIR -n brainprep -o $MODULE_DIR/doc +# $ cd $MODULE_DIR/doc +# $ make html-strict +### +name: "DocumentationBuilder" on: push: branches: - "master" + - "main" + - "dev" pull_request: branches: - "*" + workflow_dispatch: + +env: + MIN_PYTHON_VERSION: "3.12" jobs: build_and_deploy: - runs-on: ${{ matrix.os }} - if: ${{ github.ref == 'refs/heads/master' }} + runs-on: "ubuntu-latest" strategy: fail-fast: false - matrix: - os: [ubuntu-latest] - python-version: [3.12] + env: + FREESURFER_HOME: "/freesurfer/home" permissions: contents: write steps: + - name: Extract branch name + id: extract-branch + shell: bash + run: echo "BRANCH=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_OUTPUT + - name: Identify deploy type + id: deploy-type + run: | + if ${{ github.ref == 'refs/heads/master' || + github.ref == 'refs/heads/main' }}; then + echo "DEPLOY_TYPE=stable" >> $GITHUB_OUTPUT + elif ${{ github.ref == 'refs/heads/dev' }}; then + echo "DEPLOY_TYPE=dev" >> $GITHUB_OUTPUT + else + echo "DEPLOY_TYPE=pr" >> $GITHUB_OUTPUT + fi - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + - name: Set up Python + uses: actions/setup-python@v6 with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies + python-version: ${{ env.MIN_PYTHON_VERSION }} + cache: "pip" + - name: Install Python dependencies run: | python -m pip install --upgrade pip - python -m pip install --progress-bar off . - python -m pip install git+https://github.com/AGrigis/pysphinxdoc.git - python -m pip install requests "scipy<=1.15" statsmodels - python -m pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu - python -m pip install git+https://github.com/neurospin-deepinsight/brainrise.git + python -m pip install --progress-bar off ".[ci,doc]" + - name: Install apt packages + uses: awalsh128/cache-apt-pkgs-action@latest + with: + packages: dvipng texlive-latex-base texlive-latex-extra build-essential + version: 1.0 + - name: Install documentation resources + run: | + DIR=$(pwd) + which python + sphinxdoc -v 2 -p $DIR -n brainprep -o $DIR/doc - name: Compute documentation run: | - mkdir -p tmp/doc - sphinxdoc -v 2 -p . -n brainprep -o tmp/doc - cd tmp/doc - make raw-html - ls source/_static - ls build/html/_static - cp -r source/_static/* build/html/_static - cd ../.. + DIR=$(pwd) + cd $DIR/doc + echo "Documentation folder: $DIR/doc" + make html-strict + cd $DIR - name: Upload documentation as an artifact uses: actions/upload-artifact@v4 + env: + DEPLOY_TYPE: ${{ steps.deploy-type.outputs.DEPLOY_TYPE }} with: - name: html-documentation - retention-days: 15 + name: doc-${{ env.DEPLOY_TYPE }} + retention-days: 1 path: | - tmp/doc/build/html + doc/_build/html - name: Deploy + env: + DEPLOY_TYPE: ${{ steps.deploy-type.outputs.DEPLOY_TYPE }} + if: env.DEPLOY_TYPE != 'pr' uses: peaceiris/actions-gh-pages@v4 with: github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: tmp/doc/build/html - - build: - - runs-on: ${{ matrix.os }} - if: ${{ github.ref != 'refs/heads/master' }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest] - python-version: [3.12] - - permissions: - contents: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install --progress-bar off . - python -m pip install git+https://github.com/AGrigis/pysphinxdoc.git - python -m pip install requests "scipy<=1.15" statsmodels - python -m pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu - python -m pip install git+https://github.com/neurospin-deepinsight/brainrise.git - - name: Compute documentation - run: | - mkdir -p tmp/doc - sphinxdoc -v 2 -p . -n brainprep -o tmp/doc - cd tmp/doc - make raw-html - ls source/_static - ls build/html/_static - cp -r source/_static/* build/html/_static - cd ../.. + publish_dir: doc/_build/html + destination_dir: ./${{ env.DEPLOY_TYPE }} diff --git a/.github/workflows/pep8.yml b/.github/workflows/pep8.yml deleted file mode 100644 index 80890e09..00000000 --- a/.github/workflows/pep8.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: "PEP8" - -on: - push: - branches: - - "master" - pull_request: - branches: - - "*" - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: 3.12 - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install pycodestyle - python -m pip install --progress-bar off . - - name: Check Python syntax - run: | - pycodestyle brainprep --ignore="E121,E123,E126,E226,E24,E704,E402,E731,E722,E741,W503,W504,W605" diff --git a/.github/workflows/pycodestyle.yml b/.github/workflows/pycodestyle.yml new file mode 100644 index 00000000..eb7f9d06 --- /dev/null +++ b/.github/workflows/pycodestyle.yml @@ -0,0 +1,49 @@ +--- +# Workflow to check code format. +# +# To run this check locally from the repository folder: +# +# .. code-block:: bash +# +# $ pycodestyle brainprep --ignore="E121,E123,E126,E226,E24,E704,E402,E731,E722,E741,W503,W504,W605,E305,E125" +### +name: "PythonLinter[pycodestyle]" + +on: + push: + branches: + - "master" + - "main" + - "dev" + pull_request: + branches: + - "*" + workflow_dispatch: + +jobs: + build: + + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + python-version: [3.12] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install pycodestyle + pip install --progress-bar off . + - name: Check Python syntax + run: | + pycodestyle brainprep --ignore="E121,E123,E126,E226,E24,E704,E402,E731,E722,E741,W503,W504,W605,E305,E125" diff --git a/.github/workflows/pydoclint.yml b/.github/workflows/pydoclint.yml new file mode 100644 index 00000000..fba3a7e2 --- /dev/null +++ b/.github/workflows/pydoclint.yml @@ -0,0 +1,50 @@ +--- +# Workflow to check whether a docstring's sections (arguments, +# returns, raises, ...) match the function signature or function +# implementation. +# +# To run this check locally from the repository folder: +# +# .. code-block:: bash +# +# $ pydoclint brainprep +### +name: "PythonDocLinter[pydoclint]" + +on: + push: + branches: + - "master" + - "main" + - "dev" + pull_request: + branches: + - "*" + workflow_dispatch: + +jobs: + codespell: + + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + python-version: [3.12] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install pydoclint + - name: Check Python docstring syntax + run: | + pydoclint brainprep diff --git a/.github/workflows/release-documentation.yml b/.github/workflows/release-documentation.yml new file mode 100644 index 00000000..d4402134 --- /dev/null +++ b/.github/workflows/release-documentation.yml @@ -0,0 +1,91 @@ +# Workflow to build doc, and deploy it using the version as a subdomain. +# +# To run this check locally from the `doc` folder: +# +# .. code-block:: bash +# +# $ sphinxdoc -v 2 -p $MODULE_DIR -n $MODULE_NAME -o $MODULE_DIR/doc +# $ cd $MODULE_DIR/doc +# $ make html-strict +### +name: "DocumentationRelease" + +on: + workflow_dispatch: + +env: + MIN_PYTHON_VERSION: "3.12" + +jobs: + + build_and_deploy: + + runs-on: "ubuntu-latest" + strategy: + fail-fast: false + + permissions: + contents: write + + steps: + - name: Extract branch name + id: extract-branch + shell: bash + run: echo "BRANCH=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_OUTPUT + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: ${{ env.MIN_PYTHON_VERSION }} + cache: "pip" + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + python -m pip install --progress-bar off ".[ci,doc]" + - name: Install apt packages + uses: awalsh128/cache-apt-pkgs-action@latest + with: + packages: dvipng texlive-latex-base texlive-latex-extra build-essential + version: 1.0 + - name: Install documentation resources + run: | + DIR=$(pwd) + which python + sphinxdoc -v 2 -p $DIR -n brainprep -o $DIR/doc + - name: Compute documentation + run: | + DIR=$(pwd) + cd $DIR/doc + echo "Documentation folder: $DIR/doc" + make html-strict + cd $DIR + - name: Upload documentation as an artifact + uses: actions/upload-artifact@v4 + env: + BRANCH: ${{ steps.extract-branch.outputs.BRANCH }} + with: + name: doc-${{ env.BRANCH }} + retention-days: 1 + path: | + doc/_build/html + - name: Extract module version + id: extract-version + shell: python + run: | + import os + import brainprep + with open(os.environ["GITHUB_OUTPUT"], "a") as of: + of.write(f"VERSION={brainprep.__version__}") + - name: Deploy + env: + BRANCH: ${{ steps.extract-branch.outputs.BRANCH }} + ENV: ${{ steps.extract-version.outputs.VERSION }} + if: env.BRANCH == 'master' || env.BRANCH == 'main' + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: doc/_build/html + destination_dir: ./${{ env.VERSION }} diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml new file mode 100644 index 00000000..3a983af2 --- /dev/null +++ b/.github/workflows/ruff.yml @@ -0,0 +1,49 @@ +--- +# Workflow to check code format. +# +# To run this check locally from the repository folder: +# +# .. code-block:: bash +# +# $ ruff check brainprep +### +name: "PythonLinter[ruff]" + +on: + push: + branches: + - "master" + - "main" + - "dev" + pull_request: + branches: + - "*" + workflow_dispatch: + +jobs: + ruff: + + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + python-version: [3.12] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install ruff + pip install --progress-bar off . + - name: Lint with Ruff + run: | + ruff check brainprep diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 59041544..c65b24d2 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -1,12 +1,24 @@ -name: "TESTING" +# Workflow to perform testings and code coverage. +# +# To run this check locally from the `doc` folder: +# +# .. code-block:: bash +# +# $ nosetests --with-coverage --cover-package=brainprep --verbosity=2 +# --with-doctest --doctest-options='+ELLIPSIS,+NORMALIZE_WHITESPACE' +### +name: "testing[nosetests]" on: push: branches: - "master" + - "main" + - "dev" pull_request: branches: - "*" + workflow_dispatch: jobs: build: @@ -16,27 +28,29 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - python-version: [3.9, 3.12] + python-version: [3.12] + env: + FREESURFER_HOME: "/freesurfer/home" steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Install dependencies + cache: "pip" + - name: Install Python dependencies run: | python -m pip install --upgrade pip - python -m pip install pynose coverage coveralls - python -m pip install --progress-bar off . + python -m pip install --progress-bar off ".[ci]" - name: Run unit tests run: | - nosetests --with-coverage --cover-package=brainprep --verbosity=2 + nosetests --with-coverage --cover-package=brainprep --verbosity=2 --with-doctest --doctest-options='+ELLIPSIS,+NORMALIZE_WHITESPACE' - name: Coveralls - if: matrix.python-version == 3.9 + if: matrix.python-version == 3.12 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | diff --git a/.gitignore b/.gitignore index cacba5c2..40ab3002 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ __pycache__/ .Python tmp/ build/ +!tools/build/ develop-eggs/ dist/ downloads/ diff --git a/AUTHORS.rst b/AUTHORS.rst old mode 100755 new mode 100644 diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6df79ee2..8c83e830 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,26 +1,101 @@ .. -*- mode: rst -*- -0.0.1 -===== +2.1.0.dev +========= + +HIGHLIGHTS +---------- + +NEW +--- -Will be released in September 2022 +- :bdg-success:`Enhancement` Add the dmriprep workflow. +- :bdg-success:`Enhancement` Add the tbss workflow. +- :bdg-success:`Enhancement` Add the mrophologist workflow. + +Fixes +----- + +Enhancements +------------ -Highlights +Changes +------- + + +2.0.0 +===== + +HIGHLIGHTS ---------- -Work in progress. -This release includes different workflows to process antomical, functional and -diffusion MR images. -All workflows are integrated in dedicated containers to enforce reproducible -research. +his 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` Anonymize workflow outputs. +- :bdg-success:`Enhancement` Worflows 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 + using `mri_synthstrip `_. +- :bdg-success:`Enhancement` A build‑and‑test container strategy has been + released. +- :bdg-success:`Enhancement` The software version has been updated, and the + corresponding workflow has been revised accordingly. +- :bdg-success:`Enhancement` A consolidated quality‑check HTML report can + now be generated directly from the CLI. Enhancements ------------ +- :bdg-success:`API` A `keep_intermediate` argument has been added to all + workflows to retain intermediate results; useful for debugging. +- :bdg-success:`Doc` A user guide is now available. +- :bdg-success:`Enhancement` Each workflow now has its own dedicated container, + enabling easier upgrades without impacting other workflows.” +- :bdg-success:`Enhancement` The brainprep command scope has been limited to + the contents of considered container. +- :bdg-success:`Enhancement` Each workflow now includes its own dedicated + automatic quality‑check procedure. + Changes ------- -The following workflows are released: +- :bdg-danger:`Deprecation` Remove the old dmriprep workflow and tbss workflow + (will be updated in v2.1.0). +- :bdg-success:`API` Building blocks are now grouped in an interfaces + submodule. +- :bdg-danger:`Deprecation` Path custom white matter mask to `recon-all` has + been deprecated. +- :bdg-success:`Enhancement` Use '--no-annot' in surfreg to avoid using the + annotation (aparc) to help with the registration. This was described to + create some artifacts on the edge of the medial wall + (https://surfer.nmr.mgh.harvard.edu/fswiki/Xhemi). +- :bdg-danger:`Deprecation` Workflows have been renamed. + + +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. + +NEW +--- + +- :bdg-success:`API` The following workflows are released: * fsreconall * fsreconall-summary @@ -40,14 +115,12 @@ The following workflows are released: * tbss * dmriprep -Bug fixes ---------- +Fixes +----- -Contributors +Enhancements ------------ -The following people contributed to this release (from ``git shortlog -ns v0.0.1``):: +Changes +------- -* 61 Antoine Grigis -* 38 LoicDorval -* 26 JulieNeuro diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst deleted file mode 100644 index f30d4102..00000000 --- a/CONTRIBUTING.rst +++ /dev/null @@ -1,132 +0,0 @@ -Contributing to `brainprep` -=========================== - -.. |fork_logo| image:: https://upload.wikimedia.org/wikipedia/commons/d/dd/Octicons-repo-forked.svg - :height: 20 - -`brainprep` is a toolbox that provides common Deep Learning brain anatomical, -functional and diffusion MR images pre-processing scripts, as well as Quality -Control (QC) routines. - -Contents --------- - -1. `Introduction <#introduction>`_ - -2. `Issues <#issues>`_ - - a. `Asking Questions <#asking-questions>`_ - - b. `Reporting Bugs <#reporting-bugs>`_ - - c. `Requesting Features <#requesting-features>`_ - -3. `Pull Requests <#pull-requests>`_ - - a. `Content <#content>`_ - - b. `CI Tests <#ci-tests>`_ - - c. `Coverage <#coverage>`_ - - d. `Style Guide <#style-guide>`_ - -Introduction ------------- - -`brainprep` is fully open-source and as such users are welcome to fork, clone and/or reuse the software freely. -Users wishing to contribute to the development of this package, however, are kindly requested to adhere to the -following `LICENSE `_. - -Issues ------- - -The easiest way to contribute to `brainprep` is by raising a "New issue". This will give you the opportunity to ask questions, report bugs or even request new features. -Remember to use clear and descriptive titles for issues. This will help other users that encounter similar problems find quick solutions. -We also ask that you read the available documentation and browse existing issues on similar topics before raising a new issue in order to avoid repetition. - -Asking Questions -~~~~~~~~~~~~~~~~ - -Users are of course welcome to ask any question relating to `brainprep` and we will endeavour to reply as soon as possible. - -These issues should include the **help wanted** label. - -Reporting Bugs -~~~~~~~~~~~~~~ - -If you discover a bug while using brainprep please include the following details in the issue you raise: - -* your operating system and the corresponding version (*e.g.* macOS v10.14.1, Ubuntu v20.04.1, *etc.*), -* the version of Python you are using (*e.g* v3.6.7, *etc.*), -* and the error message printed or a screen capture of the terminal output. - -Be sure to list the exact steps you followed that lead to the bug you encountered so that we can attempt to recreate the conditions. -If you are aware of the source of the bug we would very much appreciate if you could provide the module(s) and line number(s) affected. -This will enable us to more rapidly fix the problem. - -These issues should include the **bug** label. - -Requesting Features -~~~~~~~~~~~~~~~~~~~ - -If you believe `brainprep` could be improved with the addition of extra functionality or features feel free to let us know. -We cannot guarantee that we will include these features, but we will certainly take your suggestions into consideration. -In order to increase your chances of having a feature included, be sure to be as clear and specific as possible as to the properties this feature should have. - -These issues should include the **enhancement** label. - -Pull Requests -------------- - -If you would like to take a more active roll in the development of `brainprep` you can do so by submitting a "Pull request". -A Pull Requests (PR) is a way by which a user can submit modifications or additions to the `brainprep` package directly. -PRs need to be reviewed by the package moderators and if accepted are merged into the master branch of the repository. - -Before making a PR, be sure to carefully read the following guidelines: - -* fork the repository from the GitHub interface, *i.e.* press the button on the top right with this - symbol |fork_logo|. - This will create an independent copy of the repository on your account. -* code the new feature in your fork, ideally by creating a new branch. -* make a pull request from the GitHub interface for this branch with a clear description of what has been done, why and what issues this relates to. -* wait for feedback and update your code if requested. - -Content -~~~~~~~ - -Every PR should correspond to a bug fix or new feature issue that has already been raised. -When you make a PR be sure to tag the issue that it resolves (*e.g.* this PR relates to issue #1). -This way the issue can be closed once the PR has been merged. - -The content of a given PR should be as concise as possible. -To that end, aim to restrict modifications to those needed to resolve a single issue. -Additional bug fixes or features should be made as separate PRs. - -CI Tests -~~~~~~~~ - -Continuous Integration (CI) tests are implemented via GithHub workflows. -All PRs must pass the CI tests before being merged. -Your PR may not be reviewed by a moderator until all CI test are passed. -Therefore, try to resolve any issues in your PR that may cause the tests to fail. -In some cases it may be necessary to modify the unit tests, but this should be clearly justified in the PR description. - -Coverage -~~~~~~~~ - -Coverage tests are implemented via `Coveralls `_. -These tests will fail if the coverage, *i.e.* the number of lines of code covered by unit tests, decreases. -When submitting new code in a PR, contributors should aim to write appropriate unit tests. -If the coverage drops significantly moderators may request unit tests be added before the PR is merged. - -Style Guide -~~~~~~~~~~~ - -All contributions should adhere to the following style guides currently implemented in `brainprep`: - -* all code should be compatible with the `brainprep` dependencies. -* all code should adhere to `PEP8 `_ standards. -* docstrings need to be provided for all new modules, methods and classes. - These should adhere to `numpydoc `_ standards. - diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..da418978 --- /dev/null +++ b/LICENSE @@ -0,0 +1,515 @@ + +CeCILL-B FREE SOFTWARE LICENSE AGREEMENT + + + Notice + +This Agreement is a Free Software license agreement that is the result +of discussions between its authors in order to ensure compliance with +the two main principles guiding its drafting: + + * firstly, compliance with the principles governing the distribution + of Free Software: access to source code, broad rights granted to + users, + * secondly, the election of a governing law, French law, with which + it is conformant, both as regards the law of torts and + intellectual property law, and the protection that it offers to + both authors and holders of the economic rights over software. + +The authors of the CeCILL-B (for Ce[a] C[nrs] I[nria] L[ogiciel] L[ibre]) +license are: + +Commissariat à l'Energie Atomique - CEA, a public scientific, technical +and industrial research establishment, having its principal place of +business at 25 rue Leblanc, immeuble Le Ponant D, 75015 Paris, France. + +Centre National de la Recherche Scientifique - CNRS, a public scientific +and technological establishment, having its principal place of business +at 3 rue Michel-Ange, 75794 Paris cedex 16, France. + +Institut National de Recherche en Informatique et en Automatique - +INRIA, a public scientific and technological establishment, having its +principal place of business at Domaine de Voluceau, Rocquencourt, BP +105, 78153 Le Chesnay cedex, France. + + + Preamble + +This Agreement is an open source software license intended to give users +significant freedom to modify and redistribute the software licensed +hereunder. + +The exercising of this freedom is conditional upon a strong obligation +of giving credits for everybody that distributes a software +incorporating a software ruled by the current license so as all +contributions to be properly identified and acknowledged. + +In consideration of access to the source code and the rights to copy, +modify and redistribute granted by the license, users are provided only +with a limited warranty and the software's author, the holder of the +economic rights, and the successive licensors only have limited liability. + +In this respect, the risks associated with loading, using, modifying +and/or developing or reproducing the software by the user are brought to +the user's attention, given its Free Software status, which may make it +complicated to use, with the result that its use is reserved for +developers and experienced professionals having in-depth computer +knowledge. Users are therefore encouraged to load and test the +suitability of the software as regards their requirements in conditions +enabling the security of their systems and/or data to be ensured and, +more generally, to use and operate it in the same conditions of +security. This Agreement may be freely reproduced and published, +provided it is not altered, and that no provisions are either added or +removed herefrom. + +This Agreement may apply to any or all software for which the holder of +the economic rights decides to submit the use thereof to its provisions. + + + Article 1 - DEFINITIONS + +For the purpose of this Agreement, when the following expressions +commence with a capital letter, they shall have the following meaning: + +Agreement: means this license agreement, and its possible subsequent +versions and annexes. + +Software: means the software in its Object Code and/or Source Code form +and, where applicable, its documentation, "as is" when the Licensee +accepts the Agreement. + +Initial Software: means the Software in its Source Code and possibly its +Object Code form and, where applicable, its documentation, "as is" when +it is first distributed under the terms and conditions of the Agreement. + +Modified Software: means the Software modified by at least one +Contribution. + +Source Code: means all the Software's instructions and program lines to +which access is required so as to modify the Software. + +Object Code: means the binary files originating from the compilation of +the Source Code. + +Holder: means the holder(s) of the economic rights over the Initial +Software. + +Licensee: means the Software user(s) having accepted the Agreement. + +Contributor: means a Licensee having made at least one Contribution. + +Licensor: means the Holder, or any other individual or legal entity, who +distributes the Software under the Agreement. + +Contribution: means any or all modifications, corrections, translations, +adaptations and/or new functions integrated into the Software by any or +all Contributors, as well as any or all Internal Modules. + +Module: means a set of sources files including their documentation that +enables supplementary functions or services in addition to those offered +by the Software. + +External Module: means any or all Modules, not derived from the +Software, so that this Module and the Software run in separate address +spaces, with one calling the other when they are run. + +Internal Module: means any or all Module, connected to the Software so +that they both execute in the same address space. + +Parties: mean both the Licensee and the Licensor. + +These expressions may be used both in singular and plural form. + + + Article 2 - PURPOSE + +The purpose of the Agreement is the grant by the Licensor to the +Licensee of a non-exclusive, transferable and worldwide license for the +Software as set forth in Article 5 hereinafter for the whole term of the +protection granted by the rights over said Software. + + + Article 3 - ACCEPTANCE + +3.1 The Licensee shall be deemed as having accepted the terms and +conditions of this Agreement upon the occurrence of the first of the +following events: + + * (i) loading the Software by any or all means, notably, by + downloading from a remote server, or by loading from a physical + medium; + * (ii) the first time the Licensee exercises any of the rights + granted hereunder. + +3.2 One copy of the Agreement, containing a notice relating to the +characteristics of the Software, to the limited warranty, and to the +fact that its use is restricted to experienced users has been provided +to the Licensee prior to its acceptance as set forth in Article 3.1 +hereinabove, and the Licensee hereby acknowledges that it has read and +understood it. + + + Article 4 - EFFECTIVE DATE AND TERM + + + 4.1 EFFECTIVE DATE + +The Agreement shall become effective on the date when it is accepted by +the Licensee as set forth in Article 3.1. + + + 4.2 TERM + +The Agreement shall remain in force for the entire legal term of +protection of the economic rights over the Software. + + + Article 5 - SCOPE OF RIGHTS GRANTED + +The Licensor hereby grants to the Licensee, who accepts, the following +rights over the Software for any or all use, and for the term of the +Agreement, on the basis of the terms and conditions set forth hereinafter. + +Besides, if the Licensor owns or comes to own one or more patents +protecting all or part of the functions of the Software or of its +components, the Licensor undertakes not to enforce the rights granted by +these patents against successive Licensees using, exploiting or +modifying the Software. If these patents are transferred, the Licensor +undertakes to have the transferees subscribe to the obligations set +forth in this paragraph. + + + 5.1 RIGHT OF USE + +The Licensee is authorized to use the Software, without any limitation +as to its fields of application, with it being hereinafter specified +that this comprises: + + 1. permanent or temporary reproduction of all or part of the Software + by any or all means and in any or all form. + + 2. loading, displaying, running, or storing the Software on any or + all medium. + + 3. entitlement to observe, study or test its operation so as to + determine the ideas and principles behind any or all constituent + elements of said Software. This shall apply when the Licensee + carries out any or all loading, displaying, running, transmission + or storage operation as regards the Software, that it is entitled + to carry out hereunder. + + + 5.2 ENTITLEMENT TO MAKE CONTRIBUTIONS + +The right to make Contributions includes the right to translate, adapt, +arrange, or make any or all modifications to the Software, and the right +to reproduce the resulting software. + +The Licensee is authorized to make any or all Contributions to the +Software provided that it includes an explicit notice that it is the +author of said Contribution and indicates the date of the creation thereof. + + + 5.3 RIGHT OF DISTRIBUTION + +In particular, the right of distribution includes the right to publish, +transmit and communicate the Software to the general public on any or +all medium, and by any or all means, and the right to market, either in +consideration of a fee, or free of charge, one or more copies of the +Software by any means. + +The Licensee is further authorized to distribute copies of the modified +or unmodified Software to third parties according to the terms and +conditions set forth hereinafter. + + + 5.3.1 DISTRIBUTION OF SOFTWARE WITHOUT MODIFICATION + +The Licensee is authorized to distribute true copies of the Software in +Source Code or Object Code form, provided that said distribution +complies with all the provisions of the Agreement and is accompanied by: + + 1. a copy of the Agreement, + + 2. a notice relating to the limitation of both the Licensor's + warranty and liability as set forth in Articles 8 and 9, + +and that, in the event that only the Object Code of the Software is +redistributed, the Licensee allows effective access to the full Source +Code of the Software at a minimum during the entire period of its +distribution of the Software, it being understood that the additional +cost of acquiring the Source Code shall not exceed the cost of +transferring the data. + + + 5.3.2 DISTRIBUTION OF MODIFIED SOFTWARE + +If the Licensee makes any Contribution to the Software, the resulting +Modified Software may be distributed under a license agreement other +than this Agreement subject to compliance with the provisions of Article +5.3.4. + + + 5.3.3 DISTRIBUTION OF EXTERNAL MODULES + +When the Licensee has developed an External Module, the terms and +conditions of this Agreement do not apply to said External Module, that +may be distributed under a separate license agreement. + + + 5.3.4 CREDITS + +Any Licensee who may distribute a Modified Software hereby expressly +agrees to: + + 1. indicate in the related documentation that it is based on the + Software licensed hereunder, and reproduce the intellectual + property notice for the Software, + + 2. ensure that written indications of the Software intended use, + intellectual property notice and license hereunder are included in + easily accessible format from the Modified Software interface, + + 3. mention, on a freely accessible website describing the Modified + Software, at least throughout the distribution term thereof, that + it is based on the Software licensed hereunder, and reproduce the + Software intellectual property notice, + + 4. where it is distributed to a third party that may distribute a + Modified Software without having to make its source code + available, make its best efforts to ensure that said third party + agrees to comply with the obligations set forth in this Article . + +If the Software, whether or not modified, is distributed with an +External Module designed for use in connection with the Software, the +Licensee shall submit said External Module to the foregoing obligations. + + + 5.3.5 COMPATIBILITY WITH THE CeCILL AND CeCILL-C LICENSES + +Where a Modified Software contains a Contribution subject to the CeCILL +license, the provisions set forth in Article 5.3.4 shall be optional. + +A Modified Software may be distributed under the CeCILL-C license. In +such a case the provisions set forth in Article 5.3.4 shall be optional. + + + Article 6 - INTELLECTUAL PROPERTY + + + 6.1 OVER THE INITIAL SOFTWARE + +The Holder owns the economic rights over the Initial Software. Any or +all use of the Initial Software is subject to compliance with the terms +and conditions under which the Holder has elected to distribute its work +and no one shall be entitled to modify the terms and conditions for the +distribution of said Initial Software. + +The Holder undertakes that the Initial Software will remain ruled at +least by this Agreement, for the duration set forth in Article 4.2. + + + 6.2 OVER THE CONTRIBUTIONS + +The Licensee who develops a Contribution is the owner of the +intellectual property rights over this Contribution as defined by +applicable law. + + + 6.3 OVER THE EXTERNAL MODULES + +The Licensee who develops an External Module is the owner of the +intellectual property rights over this External Module as defined by +applicable law and is free to choose the type of agreement that shall +govern its distribution. + + + 6.4 JOINT PROVISIONS + +The Licensee expressly undertakes: + + 1. not to remove, or modify, in any manner, the intellectual property + notices attached to the Software; + + 2. to reproduce said notices, in an identical manner, in the copies + of the Software modified or not. + +The Licensee undertakes not to directly or indirectly infringe the +intellectual property rights of the Holder and/or Contributors on the +Software and to take, where applicable, vis-à-vis its staff, any and all +measures required to ensure respect of said intellectual property rights +of the Holder and/or Contributors. + + + Article 7 - RELATED SERVICES + +7.1 Under no circumstances shall the Agreement oblige the Licensor to +provide technical assistance or maintenance services for the Software. + +However, the Licensor is entitled to offer this type of services. The +terms and conditions of such technical assistance, and/or such +maintenance, shall be set forth in a separate instrument. Only the +Licensor offering said maintenance and/or technical assistance services +shall incur liability therefor. + +7.2 Similarly, any Licensor is entitled to offer to its licensees, under +its sole responsibility, a warranty, that shall only be binding upon +itself, for the redistribution of the Software and/or the Modified +Software, under terms and conditions that it is free to decide. Said +warranty, and the financial terms and conditions of its application, +shall be subject of a separate instrument executed between the Licensor +and the Licensee. + + + Article 8 - LIABILITY + +8.1 Subject to the provisions of Article 8.2, the Licensee shall be +entitled to claim compensation for any direct loss it may have suffered +from the Software as a result of a fault on the part of the relevant +Licensor, subject to providing evidence thereof. + +8.2 The Licensor's liability is limited to the commitments made under +this Agreement and shall not be incurred as a result of in particular: +(i) loss due the Licensee's total or partial failure to fulfill its +obligations, (ii) direct or consequential loss that is suffered by the +Licensee due to the use or performance of the Software, and (iii) more +generally, any consequential loss. In particular the Parties expressly +agree that any or all pecuniary or business loss (i.e. loss of data, +loss of profits, operating loss, loss of customers or orders, +opportunity cost, any disturbance to business activities) or any or all +legal proceedings instituted against the Licensee by a third party, +shall constitute consequential loss and shall not provide entitlement to +any or all compensation from the Licensor. + + + Article 9 - WARRANTY + +9.1 The Licensee acknowledges that the scientific and technical +state-of-the-art when the Software was distributed did not enable all +possible uses to be tested and verified, nor for the presence of +possible defects to be detected. In this respect, the Licensee's +attention has been drawn to the risks associated with loading, using, +modifying and/or developing and reproducing the Software which are +reserved for experienced users. + +The Licensee shall be responsible for verifying, by any or all means, +the suitability of the product for its requirements, its good working +order, and for ensuring that it shall not cause damage to either persons +or properties. + +9.2 The Licensor hereby represents, in good faith, that it is entitled +to grant all the rights over the Software (including in particular the +rights set forth in Article 5). + +9.3 The Licensee acknowledges that the Software is supplied "as is" by +the Licensor without any other express or tacit warranty, other than +that provided for in Article 9.2 and, in particular, without any warranty +as to its commercial value, its secured, safe, innovative or relevant +nature. + +Specifically, the Licensor does not warrant that the Software is free +from any error, that it will operate without interruption, that it will +be compatible with the Licensee's own equipment and software +configuration, nor that it will meet the Licensee's requirements. + +9.4 The Licensor does not either expressly or tacitly warrant that the +Software does not infringe any third party intellectual property right +relating to a patent, software or any other property right. Therefore, +the Licensor disclaims any and all liability towards the Licensee +arising out of any or all proceedings for infringement that may be +instituted in respect of the use, modification and redistribution of the +Software. Nevertheless, should such proceedings be instituted against +the Licensee, the Licensor shall provide it with technical and legal +assistance for its defense. Such technical and legal assistance shall be +decided on a case-by-case basis between the relevant Licensor and the +Licensee pursuant to a memorandum of understanding. The Licensor +disclaims any and all liability as regards the Licensee's use of the +name of the Software. No warranty is given as regards the existence of +prior rights over the name of the Software or as regards the existence +of a trademark. + + + Article 10 - TERMINATION + +10.1 In the event of a breach by the Licensee of its obligations +hereunder, the Licensor may automatically terminate this Agreement +thirty (30) days after notice has been sent to the Licensee and has +remained ineffective. + +10.2 A Licensee whose Agreement is terminated shall no longer be +authorized to use, modify or distribute the Software. However, any +licenses that it may have granted prior to termination of the Agreement +shall remain valid subject to their having been granted in compliance +with the terms and conditions hereof. + + + Article 11 - MISCELLANEOUS + + + 11.1 EXCUSABLE EVENTS + +Neither Party shall be liable for any or all delay, or failure to +perform the Agreement, that may be attributable to an event of force +majeure, an act of God or an outside cause, such as defective +functioning or interruptions of the electricity or telecommunications +networks, network paralysis following a virus attack, intervention by +government authorities, natural disasters, water damage, earthquakes, +fire, explosions, strikes and labor unrest, war, etc. + +11.2 Any failure by either Party, on one or more occasions, to invoke +one or more of the provisions hereof, shall under no circumstances be +interpreted as being a waiver by the interested Party of its right to +invoke said provision(s) subsequently. + +11.3 The Agreement cancels and replaces any or all previous agreements, +whether written or oral, between the Parties and having the same +purpose, and constitutes the entirety of the agreement between said +Parties concerning said purpose. No supplement or modification to the +terms and conditions hereof shall be effective as between the Parties +unless it is made in writing and signed by their duly authorized +representatives. + +11.4 In the event that one or more of the provisions hereof were to +conflict with a current or future applicable act or legislative text, +said act or legislative text shall prevail, and the Parties shall make +the necessary amendments so as to comply with said act or legislative +text. All other provisions shall remain effective. Similarly, invalidity +of a provision of the Agreement, for any reason whatsoever, shall not +cause the Agreement as a whole to be invalid. + + + 11.5 LANGUAGE + +The Agreement is drafted in both French and English and both versions +are deemed authentic. + + + Article 12 - NEW VERSIONS OF THE AGREEMENT + +12.1 Any person is authorized to duplicate and distribute copies of this +Agreement. + +12.2 So as to ensure coherence, the wording of this Agreement is +protected and may only be modified by the authors of the License, who +reserve the right to periodically publish updates or new versions of the +Agreement, each with a separate number. These subsequent versions may +address new issues encountered by Free Software. + +12.3 Any Software distributed under a given version of the Agreement may +only be subsequently distributed under the same version of the Agreement +or a subsequent version. + + + Article 13 - GOVERNING LAW AND JURISDICTION + +13.1 The Agreement is governed by French law. The Parties agree to +endeavor to seek an amicable solution to any disagreements or disputes +that may arise during the performance of the Agreement. + +13.2 Failing an amicable solution within two (2) months as from their +occurrence, and unless emergency proceedings are necessary, the +disagreements or disputes shall be referred to the Paris Courts having +jurisdiction, by the more diligent Party. + + +Version 1.0 dated 2006-09-05. diff --git a/LICENSE.rst b/LICENSE.rst deleted file mode 100644 index e71dcbd3..00000000 --- a/LICENSE.rst +++ /dev/null @@ -1,5 +0,0 @@ -.. -*- mode: rst -*- - -The code is distributed under the terms of the -`CeCILL-B `_ -license, as published by the CEA-CNRS-INRIA. diff --git a/README.rst b/README.rst index 2cc1a66f..14b2acda 100644 --- a/README.rst +++ b/README.rst @@ -1,117 +1,175 @@ **Usage** -|PythonVersion|_ |License| |PoweredBy|_ +.. image:: https://img.shields.io/badge/python-3.12-blue + :target: https://img.shields.io/badge/python-3.12-blue + :alt: Python Version -**Development** - -|Coveralls|_ |Testing|_ |Pep8|_ |Doc|_ +.. image:: https://img.shields.io/badge/License-CeCILLB-blue.svg + :target: http://www.cecill.info/licences/Licence_CeCILL-B_V1-en.html + :alt: License -**Release** +.. image:: https://img.shields.io/badge/Powered%20by-CEA%2FNeuroSpin-blue.svg + :target: https://joliot.cea.fr/drf/joliot/Pages/Entites_de_recherche/NeuroSpin.aspx + :alt: Powered By CEA/NeuroSpin -|PyPi|_ |DockerANAT|_ |DockerMRIQC|_ |DockerFMRIPREP|_ |DockerDMRIPREP|_ +**Development** +.. image:: https://coveralls.io/repos/brainprepdesk/brainprep/badge.svg?branch=dev&service=github + :target: https://coveralls.io/github/brainprepdesk/brainprep + :alt: Coveralls -.. |PythonVersion| image:: https://img.shields.io/badge/python-3.9%20%7C%203.12-blue -.. _PythonVersion: target:: https://img.shields.io/badge/python-3.9%20%7C%203.12-blue +.. image:: https://github.com/brainprepdesk/brainprep/actions/workflows/testing.yml/badge.svg?branch=dev + :target: https://github.com/brainprepdesk/brainprep/actions + :alt: Testing Status -.. |Coveralls| image:: https://coveralls.io/repos/neurospin-deepinsight/brainprep/badge.svg?branch=master&service=github -.. _Coveralls: target:: https://coveralls.io/github/neurospin-deepinsight/brainprep +.. image:: https://github.com/brainprepdesk/brainprep/actions/workflows/pycodestyle.yml/badge.svg?branch=dev + :target: https://github.com/brainprepdesk/brainprep/actions + :alt: PyCodeStyle -.. |Testing| image:: https://github.com/neurospin-deepinsight/brainprep/actions/workflows/testing.yml/badge.svg -.. _Testing: target:: https://github.com/neurospin-deepinsight/brainprep/actions +.. image:: https://github.com/brainprepdesk/brainprep/actions/workflows/ruff.yml/badge.svg?branch=dev + :target: https://github.com/brainprepdesk/brainprep/actions + :alt: Ruff Linter -.. |Pep8| image:: https://github.com/neurospin-deepinsight/brainprep/actions/workflows/pep8.yml/badge.svg -.. _Pep8: target:: https://github.com/neurospin-deepinsight/brainprep/actions +.. image:: https://github.com/brainprepdesk/brainprep/actions/workflows/pydoclint.yml/badge.svg?branch=dev + :target: https://github.com/brainprepdesk/brainprep/actions + :alt: PyDocLint -.. |PyPi| image:: https://badge.fury.io/py/brainprep.svg -.. _PyPi: target:: https://badge.fury.io/py/brainprep +.. image:: https://github.com/brainprepdesk/brainprep/actions/workflows/documentation.yml/badge.svg?branch=dev + :target: https://brainprepdesk.github.io/brainprep + :alt: Documentation Status -.. |Doc| image:: https://github.com/neurospin-deepinsight/brainprep/actions/workflows/documentation.yml/badge.svg -.. _Doc: target:: https://neurospin-deepinsight.github.io/brainprep +**Release** -.. |License| image:: https://img.shields.io/badge/License-CeCILLB-blue.svg -.. _License: target:: http://www.cecill.info/licences/Licence_CeCILL-B_V1-en.html +.. image:: https://badge.fury.io/py/brainprep.svg + :target: https://pypi.org/project/brainprep + :alt: PyPI Version -.. |PoweredBy| image:: https://img.shields.io/badge/Powered%20by-CEA%2FNeuroSpin-blue.svg -.. _PoweredBy: target:: https://joliot.cea.fr/drf/joliot/Pages/Entites_de_recherche/NeuroSpin.aspx +.. image:: https://img.shields.io/docker/pulls/neurospin/brainprep-anat + :target: https://hub.docker.com/r/neurospin/brainprep-anat + :alt: Docker Pulls (ANAT) -.. |DockerANAT| image:: https://img.shields.io/docker/pulls/neurospin/brainprep-anat -.. _DockerANAT: target:: https://hub.docker.com/r/neurospin/brainprep-anat +.. image:: https://img.shields.io/docker/pulls/neurospin/brainprep-mriqc + :target: https://hub.docker.com/r/neurospin/brainprep-mriqc + :alt: Docker Pulls (MRIQC) -.. |DockerMRIQC| image:: https://img.shields.io/docker/pulls/neurospin/brainprep-mriqc -.. _DockerMRIQC: target:: https://hub.docker.com/r/neurospin/brainprep-mriqc +.. image:: https://img.shields.io/docker/pulls/neurospin/brainprep-fmriprep + :target: https://hub.docker.com/r/neurospin/brainprep-fmriprep + :alt: Docker Pulls (fMRIPrep) -.. |DockerFMRIPREP| image:: https://img.shields.io/docker/pulls/neurospin/brainprep-fmriprep -.. _DockerFMRIPREP: target:: https://hub.docker.com/r/neurospin/brainprep-fmriprep +.. image:: https://img.shields.io/docker/pulls/neurospin/brainprep-dmriprep + :target: https://hub.docker.com/r/neurospin/brainprep-dmriprep + :alt: Docker Pulls (dMRIPrep) -.. |DockerDMRIPREP| image:: https://img.shields.io/docker/pulls/neurospin/brainprep-dmriprep -.. _DockerDMRIPREP: target:: https://hub.docker.com/r/neurospin/brainprep-dmriprep +brainprep +========= -brainprep: tools for brain MRI Deep Learning pre-processing -=========================================================== +What is ``brainprep``? +====================== -\:+1: If you are using the code please add a star to the repository :+1: +``brainprep`` is a comprehensive toolbox designed to streamline the +preprocessing of brain MRI data for deep learning applications. It offers a +suite of standardized scripts tailored for anatomical, functional, and +diffusion magnetic resonance imaging (MRI), ensuring consistent and +high-quality data preparation across studies. In addition to preprocessing +pipelines, brainprep includes robust quality control (QC) routines to help +researchers assess data integrity and detect potential artifacts or anomalies. -`brainprep` is a toolbox that provides common Deep Learning brain anatomical, -functional and diffusion MR images pre-processing scripts, as well as Quality -Control (QC) routines. -You can list all available workflows by running the following command in a -command prompt: +To explore the full range of available workflows, simply run the following +command in your terminal: -.. code:: +.. code-block:: bash brainprep --help -The general idea is to provide containers to execute these workflows in order -to enforce reproducible research. +Each workflow is encapsulated within a containerized environment, promoting +reproducibility and simplifying deployment across different systems. +By leveraging container technology, brainprep minimizes dependency issues +and ensures that preprocessing steps can be executed reliably, regardless of +the underlying hardware or operating system. -This work is made available by a `community of people -`_, -amoung which the CEA Neurospin BAOBAB laboratory. +Whether you're working on large-scale neuroimaging datasets or developing +novel deep learning models for brain analysis, brainprep provides a modular, +scalable, and reproducible foundation to accelerate your research. -Important links ---------------- +Important Links +=============== -* Official source code repo: https://github.com/neurospin-deepinsight/brainprep -* HTML documentation (latest release): https://neurospin-deepinsight.github.io/brainprep -* Release notes: https://github.com/neurospin-deepinsight/brainprep/blob/master/CHANGELOG.rst +- Official source code repo: https://github.com/brainprepdesk/brainprep +- HTML documentation (stable release): https://brainprepdesk.github.io/brainprep/stable +- HTML documentation (dev): https://brainprepdesk.github.io/brainprep/dev -Where to start +Install +======= + +Latest release -------------- -Examples are available in the `gallery `_. You can also refer to the `API documentation `_. +**1. Setup a virtual environment** +We recommend that you install ``brainprep`` in a virtual Python environment, +either managed with the standard library ``venv`` or with ``conda``. +Either way, create and activate a new python environment. -Install -------- +With ``venv``: + +.. code-block:: bash + + python3 -m venv / + source //bin/activate + +Windows users should change the last line to ``\\Scripts\activate.bat`` +in order to activate their virtual environment. + +With ``conda``: + +.. code-block:: bash + + conda create -n brainprep python=3.12 + conda activate brainprep + +**2. Install brainprep with pip** + +Execute the following command in the command prompt / terminal +in the proper python environment: + +.. code-block:: bash + + python3 -m pip install -U brainprep + + +Check installation +------------------ -The code is tested for the current stable PyTorch and torchvision versions, but should work with other versions as well. Make sure you have installed all the package dependencies. Complete instructions are available `here `_. +Try importing brainprep in a Python / iPython session: +.. code-block:: python -Contributing ------------- + import brainprep -If you want to contribute to brainprep, be sure to review the `contribution guidelines <./CONTRIBUTING.rst>`_. +If no error is raised, you have installed brainprep correctly. -License -------- +Dependencies +============ -This project is under the following `LICENSE <./LICENSE.rst>`_. +The required dependencies to use the software are listed +in the file `pyproject.toml `_. Citation ======== -There is no paper published yet about `brainprep`. +There is no paper published yet about ``brainprep``. We suggest that you aknowledge the brainprep team or reference to the code -repository: |link-to-paper|. Thank you. +repository. Thank you. -.. |link-to-paper| raw:: html +.. code-block:: text - - Grigis, A. et al. (2022) BrainPrep source code (Version 0.01) [Source code]. - https://github.com/neurospin-deepinsight/brainprep + @misc{brainprep, + title = {{BrainPrep source code (Version 1.0.0)}}, + author = {Grigis, Antoine and Victor, Julie and Dorval, Loic and Duchesnay, Edouard}, + url = {https://github.com/brainprepdesk/brainprep}, + } diff --git a/brainprep/__init__.py b/brainprep/__init__.py index c1871eff..49249243 100644 --- a/brainprep/__init__.py +++ b/brainprep/__init__.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- ########################################################################## -# NSAp - Copyright (C) CEA, 2021 +# 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 @@ -8,20 +7,71 @@ ########################################################################## """ -Package that provides tools for brain MRI Deep Leanring PreProcessing. +BrainPrep is an integrated tool designed to preprocess MRI data with a strong +emphasis on **transparency**, **traceability**, **usability**, and +**portability**. + +Core Principles +--------------- + +**Transparency** + +BrainPrep is built to be understandable. Every step in the preprocessing +workflow is explicitly defined, documented, and inspectable. Users can trace +how data is transformed, which parameters are applied, and which tools are +invoked without needing to dig through opaque scripts or hidden +configurations. + +**Traceability** + +Reproducibility is central to scientific integrity. BrainPrep ensures that +every preprocessing run is traceable, with automatic logging of inputs, +outputs, tool versions, and execution metadata. This makes it easy to audit +results, share workflows, and reproduce findings across labs and platforms. + +**Usability** + +BrainPrep is designed for researchers, not just developers. Its interfaces are +intuitive, its wrappers are consistent, and its workflows are modular. +Whether you're a novice or an expert, BrainPrep aims to reduce the cognitive +load of preprocessing so you can focus on your science. + +**Portability** + +To ensure consistent execution across different systems, BrainPrep is +distributed and executed using Docker or Apptainer. This containerized approach +guarantees that all dependencies, tools, and configurations are bundled +together, eliminating environment-specific issues and simplifying deployment. + +Design Trade-offs +----------------- + +BrainPrep consciously deprioritizes: + +- **Performance**: While efficiency matters, BrainPrep favors clarity over + speed. It avoids aggressive parallelization or optimization that might + obscure the logic of the workflow. +- **BIDS Compliance**: BrainPrep supports BIDS principles but does not + enforce strict adherence. Instead, it provides flexible input/output + handling that can accommodate diverse data structures and legacy formats. + +Wrapper Architecture +-------------------- + +All preprocessing steps in BrainPrep are implemented using **decorators**, +which wrap either: + +- **Command-line tools**: These wrappers return a command or list of commands + to be executed, along with a tuple of generated output paths. +- **Python functions**: These wrappers return only a tuple of generated output + paths, encapsulating logic written directly in Python. + +This architecture promotes modularity, reusability, and clarity, making it +easy to build, inspect, and extend workflows. """ -# Imports -from .info import __version__ -from .utils import ( - write_matlabbatch, check_command, check_version, execute_command) -from .spatial import ( - scale, bet2, reorient2std, biasfield, register_affine, apply_affine, - apply_mask) -from .cortical import ( - recon_all, localgi, stats2table, interhemi_surfreg, interhemi_projection, - mri_conversion, recon_all_custom_wm_mask, recon_all_longitudinal) -from .deface import deface -from .connectivity import func_connectivity -from .tbss import ( - dtifit, tbss_1_preproc, tbss_2_reg, tbss_3_postreg, tbss_4_prestats) +from rich.traceback import install + +from ._version import __version__ + +install(show_locals=False) diff --git a/brainprep/_version.py b/brainprep/_version.py new file mode 100644 index 00000000..8c0d5d5b --- /dev/null +++ b/brainprep/_version.py @@ -0,0 +1 @@ +__version__ = "2.0.0" diff --git a/brainprep/cli.py b/brainprep/cli.py new file mode 100755 index 00000000..6aa51088 --- /dev/null +++ b/brainprep/cli.py @@ -0,0 +1,155 @@ +########################################################################## +# NSAp - Copyright (C) CEA, 2022 - 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. +########################################################################## + +""" Command-line interface (CLI) utilities for BrainPrep workflows. + +This module provides a dynamic, Fire-powered command-line interface that +automatically exposes all BrainPrep workflows 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. +""" + + +import functools +import inspect +from collections.abc import Callable + +import fire + +import brainprep.workflow as wf +from brainprep.config import DEFAULT_OPTIONS + + +def make_wrapped( + fn: Callable, + is_vbm: bool = False) -> Callable: + """ + Wrap a workflow function and extend its signature with global + configuration parameters. + + This function creates a wrapper around a BrainPrep workflow function + so that: + + - All original parameters of ``fn`` are preserved. + - All global configuration parameters from ``DEFAULT_OPTIONS`` are + added as keyword-only parameters. + - Configuration parameters passed via the CLI are extracted from + ``kwargs`` and applied through the ``Config`` context manager. + - The modified signature is exposed to Fire, ensuring that the CLI + help message displays the extended parameter list. + + Parameters + ---------- + fn : Callable + The workflow function to wrap. Its signature is inspected and + extended with additional keyword-only configuration parameters. + is_vbm : bool + Whether the wrapped function corresponds to a VBM workflow. + 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 + are included. Default False. + + Returns + ------- + method : Callable + A wrapped version of ``fn`` whose signature includes both the + original parameters and the global configuration parameters. + + Notes + ----- + The wrapper inspects the signature of ``fn`` using + ``inspect.signature`` and reconstructs a new signature that includes + both workflow-specific and global configuration parameters. Each + configuration parameter is annotated as ``"Context Manager"`` to + indicate that it is handled by the ``Config`` system rather than + passed directly to the workflow. + During execution, configuration parameters are removed from + ``kwargs`` and passed to the ``Config`` context manager. Remaining + arguments are forwarded to the underlying workflow function. + """ + @functools.wraps(fn) + def wrapped_fn(*args, **kwargs): + from brainprep.config import Config + config_params = { + key: kwargs.pop(key) + for key in DEFAULT_OPTIONS + if key in kwargs + } + with Config(**config_params): + return fn(*args, **kwargs) + + sig = inspect.signature(fn) + kwargs_in_keys = "kwargs" in sig.parameters + params = list(sig.parameters.values()) + for key, val in DEFAULT_OPTIONS.items(): + if not is_vbm and key in ( + "cat12_file", "spm12_dir", "matlab_dir", "tpm_file", + "darteltpm_file"): + continue + param = inspect.Parameter( + key, + inspect.Parameter.KEYWORD_ONLY, + annotation="Context Manager", + default=val, + ) + if kwargs_in_keys: + params.insert(-1, param) + else: + params.append(param) + wrapped_fn.__signature__ = sig.replace(parameters=params) + + return wrapped_fn + + +def main(): + """ + Entry point for the BrainPrep command-line interface. + + This function exposes all BrainPrep workflows as Fire commands. + Each workflow is wrapped so that global configuration parameters can + be passed directly from the command line. + + The CLI supports all workflows defined in ``brainprep.workflow`` and + automatically displays them in the help message. + + Notes + ----- + This function should not be called directly from Python code. + + Examples + -------- + Listing available commands:: + + $ brainprep --help + + Command help:: + + $ brainprep subject-level-qa --help + """ + commands = { + "subject-level-qa": wf.brainprep_quality_assurance, + "group-level-qa": wf.brainprep_group_quality_assurance, + "subject-level-defacing": wf.brainprep_defacing, + "group-level-defacing": wf.brainprep_group_defacing, + "subject-level-quasiraw": wf.brainprep_quasiraw, + "group-level-quasiraw": wf.brainprep_group_quasiraw, + "subject-level-sbm": wf.brainprep_sbm, + "longitudinal-sbm": wf.brainprep_longitudinal_sbm, + "group-level-sbm": wf.brainprep_group_sbm, + "subject-level-vbm": wf.brainprep_vbm, + "longitudinal-vbm": wf.brainprep_longitudinal_vbm, + "group-level-vbm": wf.brainprep_group_vbm, + "subject-level-fmriprep": wf.brainprep_fmriprep, + "group-level-fmriprep": wf.brainprep_group_fmriprep, + } + for key, fn in commands.items(): + commands[key] = make_wrapped(fn, is_vbm=key.endswith("vbm")) + fire.Fire(commands) diff --git a/brainprep/color_utils.py b/brainprep/color_utils.py deleted file mode 100644 index ec078022..00000000 --- a/brainprep/color_utils.py +++ /dev/null @@ -1,683 +0,0 @@ -# coding: utf-8 -########################################################################## -# Copyright (C) CEA, 2022 -# 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. -########################################################################## - -""" -Utility methods to print the results in a terminal using term colors. -""" - -# Imports -import os -import platform - - -IS_WINDOWS = platform.system() == "Windows" -COLOR_TERMS = ["xterm-256color", "cygwin", "xterm-color"] -IS_COLOR_TERM = "TERM" in os.environ and ( - os.environ["TERM"] in COLOR_TERMS or ( - os.environ["TERM"] == "xterm" and not IS_WINDOWS - ) -) - - -# Dictionary of term colors used for printing to terminal -fg_colors = { - "title": "gold_3b", - "subtitle": "orange_4b", - "command": "grey_46", - "result": "pink_3", - "error": "red"} - - -def HEX(color): - - xterm_colors = { - "0": "#000000", - "1": "#800000", - "2": "#008000", - "3": "#808000", - "4": "#000080", - "5": "#800080", - "6": "#008080", - "7": "#c0c0c0", - "8": "#808080", - "9": "#ff0000", - "10": "#00ff00", - "11": "#ffff00", - "12": "#0000ff", - "13": "#ff00ff", - "14": "#00ffff", - "15": "#ffffff", - "16": "#000000", - "17": "#00005f", - "18": "#000087", - "19": "#0000af", - "20": "#0000d7", - "21": "#0000ff", - "22": "#005f00", - "23": "#005f5f", - "24": "#005f87", - "25": "#005faf", - "26": "#005fd7", - "27": "#005fff", - "28": "#008700", - "29": "#00875f", - "30": "#008787", - "31": "#0087af", - "32": "#0087d7", - "33": "#0087ff", - "34": "#00af00", - "35": "#00af5f", - "36": "#00af87", - "37": "#00afaf", - "38": "#00afd7", - "39": "#00afff", - "40": "#00d700", - "41": "#00d75f", - "42": "#00d787", - "43": "#00d7af", - "44": "#00d7d7", - "45": "#00d7ff", - "46": "#00ff00", - "47": "#00ff5f", - "48": "#00ff87", - "49": "#00ffaf", - "50": "#00ffd7", - "51": "#00ffff", - "52": "#5f0000", - "53": "#5f005f", - "54": "#5f0087", - "55": "#5f00af", - "56": "#5f00d7", - "57": "#5f00ff", - "58": "#5f5f00", - "59": "#5f5f5f", - "60": "#5f5f87", - "61": "#5f5faf", - "62": "#5f5fd7", - "63": "#5f5fff", - "64": "#5f8700", - "65": "#5f875f", - "66": "#5f8787", - "67": "#5f87af", - "68": "#5f87d7", - "69": "#5f87ff", - "70": "#5faf00", - "71": "#5faf5f", - "72": "#5faf87", - "73": "#5fafaf", - "74": "#5fafd7", - "75": "#5fafff", - "76": "#5fd700", - "77": "#5fd75f", - "78": "#5fd787", - "79": "#5fd7af", - "80": "#5fd7d7", - "81": "#5fd7ff", - "82": "#5fff00", - "83": "#5fff5f", - "84": "#5fff87", - "85": "#5fffaf", - "86": "#5fffd7", - "87": "#5fffff", - "88": "#870000", - "89": "#87005f", - "90": "#870087", - "91": "#8700af", - "92": "#8700d7", - "93": "#8700ff", - "94": "#875f00", - "95": "#875f5f", - "96": "#875f87", - "97": "#875faf", - "98": "#875fd7", - "99": "#875fff", - "100": "#878700", - "101": "#87875f", - "102": "#878787", - "103": "#8787af", - "104": "#8787d7", - "105": "#8787ff", - "106": "#87af00", - "107": "#87af5f", - "108": "#87af87", - "109": "#87afaf", - "110": "#87afd7", - "111": "#87afff", - "112": "#87d700", - "113": "#87d75f", - "114": "#87d787", - "115": "#87d7af", - "116": "#87d7d7", - "117": "#87d7ff", - "118": "#87ff00", - "119": "#87ff5f", - "120": "#87ff87", - "121": "#87ffaf", - "122": "#87ffd7", - "123": "#87ffff", - "124": "#af0000", - "125": "#af005f", - "126": "#af0087", - "127": "#af00af", - "128": "#af00d7", - "129": "#af00ff", - "130": "#af5f00", - "131": "#af5f5f", - "132": "#af5f87", - "133": "#af5faf", - "134": "#af5fd7", - "135": "#af5fff", - "136": "#af8700", - "137": "#af875f", - "138": "#af8787", - "139": "#af87af", - "140": "#af87d7", - "141": "#af87ff", - "142": "#afaf00", - "143": "#afaf5f", - "144": "#afaf87", - "145": "#afafaf", - "146": "#afafd7", - "147": "#afafff", - "148": "#afd700", - "149": "#afd75f", - "150": "#afd787", - "151": "#afd7af", - "152": "#afd7d7", - "153": "#afd7ff", - "154": "#afff00", - "155": "#afff5f", - "156": "#afff87", - "157": "#afffaf", - "158": "#afffd7", - "159": "#afffff", - "160": "#d70000", - "161": "#d7005f", - "162": "#d70087", - "163": "#d700af", - "164": "#d700d7", - "165": "#d700ff", - "166": "#d75f00", - "167": "#d75f5f", - "168": "#d75f87", - "169": "#d75faf", - "170": "#d75fd7", - "171": "#d75fff", - "172": "#d78700", - "173": "#d7875f", - "174": "#d78787", - "175": "#d787af", - "176": "#d787d7", - "177": "#d787ff", - "178": "#d7af00", - "179": "#d7af5f", - "180": "#d7af87", - "181": "#d7afaf", - "182": "#d7afd7", - "183": "#d7afff", - "184": "#d7d700", - "185": "#d7d75f", - "186": "#d7d787", - "187": "#d7d7af", - "188": "#d7d7d7", - "189": "#d7d7ff", - "190": "#d7ff00", - "191": "#d7ff5f", - "192": "#d7ff87", - "193": "#d7ffaf", - "194": "#d7ffd7", - "195": "#d7ffff", - "196": "#ff0000", - "197": "#ff005f", - "198": "#ff0087", - "199": "#ff00af", - "200": "#ff00d7", - "201": "#ff00ff", - "202": "#ff5f00", - "203": "#ff5f5f", - "204": "#ff5f87", - "205": "#ff5faf", - "206": "#ff5fd7", - "207": "#ff5fff", - "208": "#ff8700", - "209": "#ff875f", - "210": "#ff8787", - "211": "#ff87af", - "212": "#ff87d7", - "213": "#ff87ff", - "214": "#ffaf00", - "215": "#ffaf5f", - "216": "#ffaf87", - "217": "#ffafaf", - "218": "#ffafd7", - "219": "#ffafff", - "220": "#ffd700", - "221": "#ffd75f", - "222": "#ffd787", - "223": "#ffd7af", - "224": "#ffd7d7", - "225": "#ffd7ff", - "226": "#ffff00", - "227": "#ffff5f", - "228": "#ffff87", - "229": "#ffffaf", - "230": "#ffffd7", - "231": "#ffffff", - "232": "#080808", - "233": "#121212", - "234": "#1c1c1c", - "235": "#262626", - "236": "#303030", - "237": "#3a3a3a", - "238": "#444444", - "239": "#4e4e4e", - "240": "#585858", - "241": "#626262", - "242": "#6c6c6c", - "243": "#767676", - "244": "#808080", - "245": "#8a8a8a", - "246": "#949494", - "247": "#9e9e9e", - "248": "#a8a8a8", - "249": "#b2b2b2", - "250": "#bcbcbc", - "251": "#c6c6c6", - "252": "#d0d0d0", - "253": "#dadada", - "254": "#e4e4e4", - "255": "#eeeeee" - } - - # swap keys for values - new_xterm_colors = dict(zip(xterm_colors.values(), xterm_colors.keys())) - return new_xterm_colors[color] - - -class colored(object): - - def __init__(self, color): - - self.ESC = "\x1b[" - self.END = "m" - self.color = color - - if str(color).startswith("#"): - self.HEX = HEX(color.lower()) - else: - self.HEX = "" - - self.paint = { - "black": "0", - "red": "1", - "green": "2", - "yellow": "3", - "blue": "4", - "magenta": "5", - "cyan": "6", - "light_gray": "7", - "dark_gray": "8", - "light_red": "9", - "light_green": "10", - "light_yellow": "11", - "light_blue": "12", - "light_magenta": "13", - "light_cyan": "14", - "white": "15", - "grey_0": "16", - "navy_blue": "17", - "dark_blue": "18", - "blue_3a": "19", - "blue_3b": "20", - "blue_1": "21", - "dark_green": "22", - "deep_sky_blue_4a": "23", - "deep_sky_blue_4b": "24", - "deep_sky_blue_4c": "25", - "dodger_blue_3": "26", - "dodger_blue_2": "27", - "green_4": "28", - "spring_green_4": "29", - "turquoise_4": "30", - "deep_sky_blue_3a": "31", - "deep_sky_blue_3b": "32", - "dodger_blue_1": "33", - "green_3a": "34", - "spring_green_3a": "35", - "dark_cyan": "36", - "light_sea_green": "37", - "deep_sky_blue_2": "38", - "deep_sky_blue_1": "39", - "green_3b": "40", - "spring_green_3b": "41", - "spring_green_2a": "42", - "cyan_3": "43", - "dark_turquoise": "44", - "turquoise_2": "45", - "green_1": "46", - "spring_green_2b": "47", - "spring_green_1": "48", - "medium_spring_green": "49", - "cyan_2": "50", - "cyan_1": "51", - "dark_red_1": "52", - "deep_pink_4a": "53", - "purple_4a": "54", - "purple_4b": "55", - "purple_3": "56", - "blue_violet": "57", - "orange_4a": "58", - "grey_37": "59", - "medium_purple_4": "60", - "slate_blue_3a": "61", - "slate_blue_3b": "62", - "royal_blue_1": "63", - "chartreuse_4": "64", - "dark_sea_green_4a": "65", - "pale_turquoise_4": "66", - "steel_blue": "67", - "steel_blue_3": "68", - "cornflower_blue": "69", - "chartreuse_3a": "70", - "dark_sea_green_4b": "71", - "cadet_blue_2": "72", - "cadet_blue_1": "73", - "sky_blue_3": "74", - "steel_blue_1a": "75", - "chartreuse_3b": "76", - "pale_green_3a": "77", - "sea_green_3": "78", - "aquamarine_3": "79", - "medium_turquoise": "80", - "steel_blue_1b": "81", - "chartreuse_2a": "82", - "sea_green_2": "83", - "sea_green_1a": "84", - "sea_green_1b": "85", - "aquamarine_1a": "86", - "dark_slate_gray_2": "87", - "dark_red_2": "88", - "deep_pink_4b": "89", - "dark_magenta_1": "90", - "dark_magenta_2": "91", - "dark_violet_1a": "92", - "purple_1a": "93", - "orange_4b": "94", - "light_pink_4": "95", - "plum_4": "96", - "medium_purple_3a": "97", - "medium_purple_3b": "98", - "slate_blue_1": "99", - "yellow_4a": "100", - "wheat_4": "101", - "grey_53": "102", - "light_slate_grey": "103", - "medium_purple": "104", - "light_slate_blue": "105", - "yellow_4b": "106", - "dark_olive_green_3a": "107", - "dark_green_sea": "108", - "light_sky_blue_3a": "109", - "light_sky_blue_3b": "110", - "sky_blue_2": "111", - "chartreuse_2b": "112", - "dark_olive_green_3b": "113", - "pale_green_3b": "114", - "dark_sea_green_3a": "115", - "dark_slate_gray_3": "116", - "sky_blue_1": "117", - "chartreuse_1": "118", - "light_green_2": "119", - "light_green_3": "120", - "pale_green_1a": "121", - "aquamarine_1b": "122", - "dark_slate_gray_1": "123", - "red_3a": "124", - "deep_pink_4c": "125", - "medium_violet_red": "126", - "magenta_3a": "127", - "dark_violet_1b": "128", - "purple_1b": "129", - "dark_orange_3a": "130", - "indian_red_1a": "131", - "hot_pink_3a": "132", - "medium_orchid_3": "133", - "medium_orchid": "134", - "medium_purple_2a": "135", - "dark_goldenrod": "136", - "light_salmon_3a": "137", - "rosy_brown": "138", - "grey_63": "139", - "medium_purple_2b": "140", - "medium_purple_1": "141", - "gold_3a": "142", - "dark_khaki": "143", - "navajo_white_3": "144", - "grey_69": "145", - "light_steel_blue_3": "146", - "light_steel_blue": "147", - "yellow_3a": "148", - "dark_olive_green_3": "149", - "dark_sea_green_3b": "150", - "dark_sea_green_2": "151", - "light_cyan_3": "152", - "light_sky_blue_1": "153", - "green_yellow": "154", - "dark_olive_green_2": "155", - "pale_green_1b": "156", - "dark_sea_green_5b": "157", - "dark_sea_green_5a": "158", - "pale_turquoise_1": "159", - "red_3b": "160", - "deep_pink_3a": "161", - "deep_pink_3b": "162", - "magenta_3b": "163", - "magenta_3c": "164", - "magenta_2a": "165", - "dark_orange_3b": "166", - "indian_red_1b": "167", - "hot_pink_3b": "168", - "hot_pink_2": "169", - "orchid": "170", - "medium_orchid_1a": "171", - "orange_3": "172", - "light_salmon_3b": "173", - "light_pink_3": "174", - "pink_3": "175", - "plum_3": "176", - "violet": "177", - "gold_3b": "178", - "light_goldenrod_3": "179", - "tan": "180", - "misty_rose_3": "181", - "thistle_3": "182", - "plum_2": "183", - "yellow_3b": "184", - "khaki_3": "185", - "light_goldenrod_2a": "186", - "light_yellow_3": "187", - "grey_84": "188", - "light_steel_blue_1": "189", - "yellow_2": "190", - "dark_olive_green_1a": "191", - "dark_olive_green_1b": "192", - "dark_sea_green_1": "193", - "honeydew_2": "194", - "light_cyan_1": "195", - "red_1": "196", - "deep_pink_2": "197", - "deep_pink_1a": "198", - "deep_pink_1b": "199", - "magenta_2b": "200", - "magenta_1": "201", - "orange_red_1": "202", - "indian_red_1c": "203", - "indian_red_1d": "204", - "hot_pink_1a": "205", - "hot_pink_1b": "206", - "medium_orchid_1b": "207", - "dark_orange": "208", - "salmon_1": "209", - "light_coral": "210", - "pale_violet_red_1": "211", - "orchid_2": "212", - "orchid_1": "213", - "orange_1": "214", - "sandy_brown": "215", - "light_salmon_1": "216", - "light_pink_1": "217", - "pink_1": "218", - "plum_1": "219", - "gold_1": "220", - "light_goldenrod_2b": "221", - "light_goldenrod_2c": "222", - "navajo_white_1": "223", - "misty_rose1": "224", - "thistle_1": "225", - "yellow_1": "226", - "light_goldenrod_1": "227", - "khaki_1": "228", - "wheat_1": "229", - "cornsilk_1": "230", - "grey_100": "231", - "grey_3": "232", - "grey_7": "233", - "grey_11": "234", - "grey_15": "235", - "grey_19": "236", - "grey_23": "237", - "grey_27": "238", - "grey_30": "239", - "grey_35": "240", - "grey_39": "241", - "grey_42": "242", - "grey_46": "243", - "grey_50": "244", - "grey_54": "245", - "grey_58": "246", - "grey_62": "247", - "grey_66": "248", - "grey_70": "249", - "grey_74": "250", - "grey_78": "251", - "grey_82": "252", - "grey_85": "253", - "grey_89": "254", - "grey_93": "255", - } - - def attribute(self): - """Set or reset attributes""" - - paint = { - "bold": self.ESC + "1" + self.END, - 1: self.ESC + "1" + self.END, - "dim": self.ESC + "2" + self.END, - 2: self.ESC + "2" + self.END, - "underlined": self.ESC + "4" + self.END, - 4: self.ESC + "4" + self.END, - "blink": self.ESC + "5" + self.END, - 5: self.ESC + "5" + self.END, - "reverse": self.ESC + "7" + self.END, - 7: self.ESC + "7" + self.END, - "hidden": self.ESC + "8" + self.END, - 8: self.ESC + "8" + self.END, - "reset": self.ESC + "0" + self.END, - 0: self.ESC + "0" + self.END, - "res_bold": self.ESC + "21" + self.END, - 21: self.ESC + "21" + self.END, - "res_dim": self.ESC + "22" + self.END, - 22: self.ESC + "22" + self.END, - "res_underlined": self.ESC + "24" + self.END, - 24: self.ESC + "24" + self.END, - "res_blink": self.ESC + "25" + self.END, - 25: self.ESC + "25" + self.END, - "res_reverse": self.ESC + "27" + self.END, - 27: self.ESC + "27" + self.END, - "res_hidden": self.ESC + "28" + self.END, - 28: self.ESC + "28" + self.END, - } - return paint[self.color] - - def foreground(self): - """Print 256 foreground colors""" - code = self.ESC + "38;5;" - if str(self.color).isdigit(): - self.reverse_dict() - color = self.reserve_paint[str(self.color)] - return code + self.paint[color] + self.END - elif self.color.startswith("#"): - return code + str(self.HEX) + self.END - else: - return code + self.paint[self.color] + self.END - - def background(self): - """Print 256 background colors""" - code = self.ESC + "48;5;" - if str(self.color).isdigit(): - self.reverse_dict() - color = self.reserve_paint[str(self.color)] - return code + self.paint[color] + self.END - elif self.color.startswith("#"): - return code + str(self.HEX) + self.END - else: - return code + self.paint[self.color] + self.END - - def reverse_dict(self): - """reverse dictionary""" - self.reserve_paint = dict(zip(self.paint.values(), self.paint.keys())) - - -def stylize(text, styles, reset=True): - """ Conveniently styles your text as and resets ANSI codes at its end. - """ - terminator = attr("reset") if reset else "" - return "{}{}{}".format("".join(styles), text, terminator) - - -def fg(color): - """ Alias for colored().foreground(). - """ - return colored(color).foreground() - - -def attr(color): - """ Alias for colored().attribute(). - """ - return colored(color).attribute() - - -def print_title(title): - if IS_COLOR_TERM: - title = stylize(title, fg(fg_colors["title"]) + attr("bold")) - print(title) - - -def print_subtitle(title): - if IS_COLOR_TERM: - title = stylize(title, fg(fg_colors["subtitle"])) - print(title) - - -def print_command(command): - if IS_COLOR_TERM: - command = stylize(command, fg(fg_colors["command"])) - print(command) - - -def print_result(result): - if IS_COLOR_TERM: - result = stylize(result, fg(fg_colors["result"])) - print(result) - - -def print_error(error): - if IS_COLOR_TERM: - error = stylize(error, fg(fg_colors["error"])) - print(error) diff --git a/brainprep/config.py b/brainprep/config.py new file mode 100644 index 00000000..6538d501 --- /dev/null +++ b/brainprep/config.py @@ -0,0 +1,103 @@ +########################################################################## +# NSAp - Copyright (C) CEA, 2022 - 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. +########################################################################## + +""" +Configuration used by BrainPrep. +""" + +import contextvars +from pathlib import Path + +DEFAULT_OPTIONS = { + "verbose": True, + "dryrun": False, + "no_color": False, + "skip_run_check": False, + "cat12_file": Path( + "/opt/cat12/standalone/cat_standalone.sh" + ), + "spm12_dir": Path( + "/opt/cat12" + ), + "matlab_dir": Path( + "/opt/MCR-2017b/v93" + ), + "tpm_file": Path( + "/opt/cat12/spm12_mcr/home/gaser/gaser/spm/spm12/tpm/TPM.nii" + ), + "darteltpm_file": Path( + "/opt/cat12/spm12_mcr/home/gaser/gaser/spm/spm12/toolbox/" + "cat12/templates_MNI152NLin2009cAsym/Template_1_Dartel.nii" + ), +} + +brainprep_options = contextvars.ContextVar( + "brainprep_options", + default=DEFAULT_OPTIONS, +) + + +class Config: + """ + Context manager for modifying global execution options. + + This context manager allows temporary overrides of global options such as + `verbose` and `dryrun`, which are commonly used across the `brainprep` + package to control execution behavior. These options are primarily + consumed by the :mod:`~brainprep.interfaces` and + :func:`~brainprep.reporting` modules when executing commands from + decorated functions. + + Additional options may be passed to workflows. These include + resource-specific flags and configuration pre-configured to work with + the associated container resource. + + Parameters + ---------- + **options : dict + Keyword arguments intercepted by the wrapper function: + - verbose : bool, default True - print information or not. + - dryrun : bool, default False - execute commands or not + - no_color : bool, default False - print with colors or not. + - skip_run_check : bool, default False - check run unicity or not. + - cat12_file : File - path to the CAT12 standalone executable. + - spm12_dir : Directory - path to the SPM12 standalone executable. + - matlab_dir : Directory - path to the Matlab Compiler Runtime (MCR). + - tpm_file : File - path to the SPM TPM file. + - darteltpm_file : File - path to the CAT12 template file. + + Notes + ----- + - The context variable `brainprep_options` holds the current + configuration. + - Options are scoped to the `with` block and automatically restored + afterward. + + Examples + -------- + >>> from brainprep.config import Config + >>> from brainprep.interfaces import movedir + >>> + >>> with Config(dryrun=True, verbose=False): + ... target_directory = movedir( + ... source_dir="/tmp/source_dir", + ... output_dir="/tmp/destination_dir", + ... ) + >>> target_directory + PosixPath('/tmp/destination_dir/source_dir') + """ + + def __init__(self, **options: dict): + self.token = None + self.options = options + + def __enter__(self): + self.token = brainprep_options.set(self.options) + + def __exit__(self, exc_type, exc_val, exc_tb): + brainprep_options.reset(self.token) diff --git a/brainprep/connectivity.py b/brainprep/connectivity.py deleted file mode 100644 index b95586de..00000000 --- a/brainprep/connectivity.py +++ /dev/null @@ -1,221 +0,0 @@ -# -*- coding: utf-8 -*- -########################################################################## -# NSAp - Copyright (C) CEA, 2022 -# 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. -########################################################################## - - -""" -Import pre-processed images and confounds from fMRIPrep and generate -connectivity matrices based on standard parcellations and metrics. -""" - -# Imports -import os -import numpy as np -from nilearn import datasets -from nilearn import plotting -from nilearn.image import clean_img -from nilearn.maskers import NiftiLabelsMasker -from nilearn.connectome import ConnectivityMeasure -from nilearn.interfaces.fmriprep import load_confounds -from .color_utils import print_subtitle, print_result - - -# Define global parameters -CONNECTIVITIES = ["correlation", "partial correlation"] -ATLASES = ["schaefer"] - - -def func_connectivity(fmri_file, counfounds_file, mask_file, - tr, outdir, low_pass=0.1, high_pass=0.01, scrub=5, - fd_threshold=0.2, std_dvars_threshold=3, detrend=True, - standardize=True, remove_volumes=False, fwhm=0.): - """ Compute ROI-based functional connectivity from fMRIPrep pre-processing. - - This function applies the Yeo et al. (2011) timeseries pre-processing - schema: - - * detrend. - * low- and high-pass filters. - * remove confounds. - * standardize. - - The filtering stage is composed of: - - * low pass filter out high frequency signals from the data (upper than - 0.1 Hz by default). fMRI signals are slow evolving processes, any high - frequency signals are likely due to noise. - * high pass filter out any very low frequency signals (below 0.001 Hz by - default), which may be due to intrinsic scanner instabilities. - - The confound regressors are composed of: - - * 1 global signal. - * 12 motion parameters + derivatives. - * 8 discrete cosines transformation basis regressors to handle - low-frequency signal drifts. - * 2 confounds derived from white matter and cerebrospinal fluid. - - This is a total of 23 base confound regressor variables. - - According to Lindquist et al. (2018), removal of confounds will be done - orthogonally to temporal filters (low- and/or high-pass filters), if both - are specified. - - Notes - ----- - Connectivity extraction parameters can be changed by setting the following - module global parameters: CONNECTIVITIES, ATLASES. - - Parameters - ---------- - fmri_file: str - the fMRIPrep pre-processing file: ***desc-preproc_bold.nii.gz**. - counfounds_file: str - the path to the fMRIPrep counfounds file: - ***desc-confounds_regressors.tsv**. - mask_file: str - signal is only cleaned from voxels inside the mask. It should have the - same shape and affine as the ``fmri_file``: - ***desc-brain_mask.nii.gz**. - tr: float - the repetition time (TR) in seconds. - outdir: str - the destination folder. - low_pass: float, default 0.1 - the low-pass filter cutoff frequency in Hz. Set it to ``None`` if you - dont want low-pass filtering. - high_pass: float, default 0.01 - the high-pass filter cutoff frequency in Hz. Set it to ``None`` if you - dont want high-pass filtering. - scrub: int, default 5 - after accounting for time frames with excessive motion, further remove - segments shorter than the given number. The default value is 5. When - the value is 0, remove time frames based on excessive framewise - displacement and DVARS only. One-hot encoding vectors are added as - regressors for each scrubbed frame. - fd_threshold: float, default 0.2 - Framewise displacement threshold for scrub. This value is typically - between 0 and 1 mm. - std_dvars_threshold: float, default 3 - standardized DVARS threshold for scrub. DVARs is defined as root mean - squared intensity difference of volume N to volume N + 1. D refers - to temporal derivative of timecourses, VARS referring to root mean - squared variance over voxels. - detrend: bool, default True - detrend data prior to confound removal. - standardize: default True - set this flag if you want to standardize the output signal between - [0 1]. - remove_volumes: bool, default False - this flag determines whether contaminated volumes should be removed - from the output data. - fwhm: float or list, default 0. - smoothing strength, expressed as as Full-Width at Half Maximum - (fwhm), in millimeters. Can be a single number ``fwhm=8``, the width - is identical along x, y and z or ``fwhm=0``, no smoothing is peformed. - Can be three consecutive numbers, ``fwhm=[1,1.5,2.5]``, giving the fwhm - along each axis. - - Returns - ------- - corrfiles: dict - the connectivity matrix resulting files. - """ - print_subtitle("Get connectivity extraction parameters...") - basename = os.path.basename(fmri_file).split(".")[0] - assert basename.endswith("_bold"), basename - basename = basename.replace("_bold", "_mod-bold") - print("- connectivities:", "-".join(CONNECTIVITIES)) - print("- atlases:", "-".join(ATLASES)) - print("- fMRI volume:", fmri_file) - print("- counfounds file:", counfounds_file) - print("- mask file:", mask_file) - - print_subtitle("Get atlases...") - atlasdir = os.path.join(outdir, "atlases") - if not os.path.isdir(atlasdir): - os.mkdir(atlasdir) - data = {} - for atlas_name in ATLASES: - if atlas_name == "schaefer": - atlas = datasets.fetch_atlas_schaefer_2018( - n_rois=200, data_dir=atlasdir) - elif atlass_name == "msdl": - atlas = datasets.fetch_atlas_msdl(data_dir=atlasdir) - else: - raise ValueError("Unsupported atlas '{}'.".format(atlas)) - data[atlas_name] = { - "atlas_filename": atlas.maps, - "atlas_labels": atlas.labels} - atlas_snap = os.path.join(atlasdir, "{}.png".format(atlas_name)) - if not os.path.isfile(atlas_snap): - plotting.plot_roi( - data[atlas_name]["atlas_filename"], title=atlas_name, - cut_coords=(8, -4, 9), colorbar=True, cmap="Paired", - output_file=atlas_snap) - print_result(atlas_snap) - - print_subtitle("Get requested counfounds...") - select_confounds, sample_mask = load_confounds( - fmri_file, - strategy=["high_pass", "motion", "wm_csf", "global_signal"], - motion="derivatives", wm_csf="basic", global_signal="basic", - scrub=scrub, fd_threshold=fd_threshold, - std_dvars_threshold=std_dvars_threshold) - if not remove_volumes: - sample_mask = None - print(select_confounds) - - print_subtitle("Clean fMRI timeseries...") - clean_im = clean_img( - fmri_file, standardize=standardize, detrend=detrend, - confounds=select_confounds, t_r=tr, high_pass=high_pass, - low_pass=low_pass, mask_img=mask_file) - - if np.array(fwhm).sum() > 0.0: - print_subtitle("Smooth fMRI timeseries...") - smooth_im = nl_img.smooth_img(clean_im, fwhm) - - print_subtitle("Extract average fMRI timeseries...") - for atlas_name, params in data.items(): - masker = NiftiLabelsMasker( - labels_img=params["atlas_filename"], verbose=5) - timeseries = masker.fit_transform(clean_im, - sample_mask=sample_mask) - params["timeseries"] = timeseries - - print_subtitle("Compute functional connectivity...") - for metric in CONNECTIVITIES: - correlation_measure = ConnectivityMeasure(kind=metric) - for atlas_name, params in data.items(): - correlation_matrix = correlation_measure.fit_transform( - [params["timeseries"]])[0] - np.fill_diagonal(correlation_matrix, 0) - corr_snap = os.path.join( - outdir, basename + "atlas-{}_{}.png".format( - atlas_name, metric.replace(" ", ""))) - display = plotting.plot_matrix( - correlation_matrix, figure=(10, 8), - labels=params["atlas_labels"], reorder=True, - title="{}-{}".format(atlas_name, metric)) - display.figure.savefig(corr_snap) - print_result(corr_snap) - params[metric] = correlation_matrix - - print_subtitle("Saving results...") - corrfiles = {} - for atlas_name, params in data.items(): - for metric in CONNECTIVITIES: - corr_file = os.path.join( - outdir, basename + "atlas-{}_{}.npy".format( - atlas_name, metric.replace(" ", ""))) - np.save(corr_file, params[metric]) - print_result(corr_file) - corrfiles[metric] = corr_file - - return corrfiles diff --git a/brainprep/cortical.py b/brainprep/cortical.py deleted file mode 100644 index 0ba85f8b..00000000 --- a/brainprep/cortical.py +++ /dev/null @@ -1,461 +0,0 @@ -# -*- coding: utf-8 -*- -########################################################################## -# NSAp - Copyright (C) CEA, 2021 -# 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. -########################################################################## - - -""" -Common cortical functions. -""" - -# Imports -import os -import glob -import shutil -import tempfile -import warnings -from .utils import check_command, execute_command - - -def recon_all(fsdir, anatfile, sid, reconstruction_stage="all", resume=False, - t2file=None, flairfile=None): - """ Performs all the FreeSurfer cortical reconstruction steps. - - .. note:: This function is based on FreeSurfer. - - Parameters - ---------- - fsdir: str - the FreeSurfer working directory with all the subjects. - anatfile: str - the input anatomical image to be segmented with FreeSurfer. - sid: str - the current subject identifier. - reconstruction_stage: str, default 'all' - the FreeSurfer reconstruction stage that will be launched. - resume: bool, deafult False - if true, try to resume the recon-all. This option is also usefull if - custom segmentation is used in recon-all. - t2file: str, default None - specify the path to a T2 image that will be used to improve the pial - surfaces. - flairfile: str, default None - specify the path to a FLAIR image that will be used to improve the pial - surfaces. - - Returns - ------- - subjfsdir: str - path to the resulting FreeSurfer segmentation. - """ - # Check input parameters - if not os.path.isdir(fsdir): - raise ValueError("'{0}' FreeSurfer home directory does not " - "exists.".format(fsdir)) - if reconstruction_stage not in ("all", "autorecon1", "autorecon2", - "autorecon2-cp", "autorecon2-wm", - "autorecon2-pial", "autorecon3"): - raise ValueError("Unsupported '{0}' recon-all reconstruction " - "stage.".format(reconstruction_stage)) - - # Call FreeSurfer segmentation - check_command("recon-all") - cmd = ["recon-all", "-{0}".format(reconstruction_stage), "-subjid", sid, - "-i", anatfile, "-sd", fsdir, "-noappend", "-no-isrunning"] - if t2file is not None: - cmd.extend(["-T2", t2file, "-T2pial"]) - if flairfile is not None: - cmd.extend(["-FLAIR", t2file, "-FLAIRpial"]) - if resume: - cmd[1] = "-make all" - execute_command(cmd) - subjfsdir = os.path.join(fsdir, sid) - - return subjfsdir - - -def recon_all_custom_wm_mask(fsdir, sid, wm): - """ Assuming you have run recon-all (at least upto wm.mgz creation), this - function allows to rerun recon-all using a custom white matter mask. - - Parameters - ---------- - fsdir: str - the FreeSurfer working directory with all the subjects. - sid: str - the current subject identifier. - wm: str - path to the custom white matter mask. 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. - - Returns - ------- - subjfsdir: str - path to the resulting FreeSurfer segmentation. - """ - # Check existence of the subject's directory - subjfsdir = os.path.join(fsdir, sid) - if not os.path.isdir(subjfsdir): - raise ValueError(f"Directory does not exist: {subjfsdir}.") - - # Save original wm.seg.mgz as wm.seg.orig.mgz - wm_seg_mgz = os.path.join(subjfsdir, "mri", "wm.seg.mgz") - save_as = os.path.join(subjfsdir, "mri", "wm.seg.orig.mgz") - shutil.move(wm_seg_mgz, save_as) - - # Work in tmp - with tempfile.TemporaryDirectory() as tmpdir: - - # Change input mask range of values: [0-1] to [0-110] - wm_mask_0_110 = os.path.join(tmpdir, "wm_mask_0_110.nii.gz") - cmd = ["mris_calc", "-o", wm_mask_0_110, wm, "mul", "110"] - check_command("mris_calc") - execute_command(cmd) - - # Write the new wm.seg.mgz, FreeSurfer requires MRI_UCHAR type - cmd = ["mri_convert", wm_mask_0_110, wm_seg_mgz, "-odt", "uchar"] - check_command("mri_convert") - execute_command(cmd) - - # Rerun recon-all - cmd = ["recon-all", "-autorecon2-wm", "-autorecon3", "-s", sid, - "-sd", fsdir] - check_command("recon-all") - execute_command(cmd) - - return subjfsdir - - -def recon_all_longitudinal(fsdirs, sid, outdir, timepoints=None): - """ Assuming you have run recon-all for all timepoints of a given subject, - and that the results are stored in one subject directory per timepoint, - this function will: - - - create a template for the subject and process it with recon-all - - rerun recon-all for all timepoints of the subject using the template - - Parameters - ---------- - fsdirs: list of str - the FreeSurfer working directory where to find the the subject - associated timepoints. - sid: str - the current subject identifier. - outdir: str - destination folder. - timepoints: list of str, default None - the timepoint names in the same order as the ``subjfsdirs``. - Used to create the subject longitudinal IDs. By default timepoints - are "1", "2"... - - Returns - ------- - template_id: str - ID of the subject template. - long_sids: list of str - longitudinal IDs of the subject for all the timepoints. - """ - # Check existence of FreeSurfer subject directories - for fsdir in fsdirs: - subjfsdir = os.path.join(fsdir, sid) - if not os.path.isdir(subjfsdir): - raise ValueError("Directory does not exist: {subjfsdir}.") - - # If 'timepoints' not passed, used defaults, else check validity - if timepoints is None: - timepoints = [str(n) for n in range(1, len(fsdirs) + 1)] - elif len(timepoints) != len(fsdirs): - raise ValueError("There should be as many timepoints as 'fsdirs'.") - - # Create destination folder if necessary - if not os.path.isdir(outdir): - os.mkdir(outdir) - - # FreeSurfer requires a unique SUBJECTS_DIR with all the timepoints to - # compute the template: create symbolic links in to all timepoints - tp_sids = [] - for tp, fsdir in zip(timepoints, fsdirs): - tp_sid = f"{sid}_{tp}" - src_path = os.path.join(fsdir, sid) - dst_path = os.path.join(outdir, tp_sid) - if not os.path.islink(dst_path): - os.symlink(src_path, dst_path) - tp_sids.append(tp_sid) - - # STEP 1 - create and process template - template_id = "{}_template_{}".format(sid, "_".join(timepoints)) - cmd = ["recon-all", "-base", template_id] - for tp_sid in tp_sids: - cmd += ["-tp", tp_sid] - cmd += ["-all", "-sd", outdir] - check_command("recon-all") - execute_command(cmd) - - # STEP 2 - rerun recon-all for all timepoints using the template - long_sids = [] - for tp_sid in tp_sids: - cmd = ["recon-all", "-long", tp_sid, template_id, - "-all", "-sd", outdir] - execute_command(cmd) - long_sids += [f"{tp_sid}.long.{template_id}"] - - return template_id, long_sids - - -def interhemi_surfreg(fsdir, sid, template_dir): - """ Surface-based interhemispheric registration by applying an existing - atlas, the 'fsaverage_sym'. - - References - ---------- - Greve, Douglas N., Lise Van der Haegen, Qing Cai, Steven Stufflebeam, - Mert R. Sabuncu, Bruce Fischl, and Marc Bysbaert, A surface-based analysis - of language lateralization and cortical asymmetry, Journal of Cognitive - Neuroscience 25.9: 1477-1492 2013. - - Parameters - ---------- - fsdir: str - the FreeSurfer subjects directory 'SUBJECTS_DIR'. - sid: str - the subject identifier. - template_dir: str - path to the 'fsaverage_sym' template. - - Returns - ------- - xhemidir: str - the symetrized hemispheres. - spherefile: str - the registration file to the template. - """ - # Check input parameters - hemi = "lh" - subjfsdir = os.path.join(fsdir, sid) - if not os.path.isdir(subjfsdir): - raise ValueError("'{0}' is not a valid directory.".format(subjfsdir)) - - # Symlink input data in destination foler - dest_template_dir = os.path.join(fsdir, "fsaverage_sym") - if not os.path.islink(dest_template_dir): - os.symlink(template_dir, dest_template_dir) - - # Create the commands - os.environ["SUBJECTS_DIR"] = fsdir - sym_template_file = os.path.join( - subjfsdir, "surf", "{0}.fsaverage_sym.sphere.reg".format(hemi)) - if os.path.isfile(sym_template_file): - os.remove(sym_template_file) - cmds = [ - ["surfreg", "--s", sid, "--t", "fsaverage_sym", - "--{0}".format(hemi)], - ["xhemireg", "--s", sid], - ["surfreg", "--s", sid, "--t", "fsaverage_sym", - "--{0}".format(hemi), "--xhemi"]] - - # Call FreeSurfer xhemi - check_command("surfreg") - check_command("xhemireg") - for cmd in cmds: - execute_command(cmd) - - # Get outputs - xhemidir = os.path.join(subjfsdir, "xhemi") - spherefile = os.path.join( - subjfsdir, "surf", "{0}.fsaverage_sym.sphere.reg".format(hemi)) - - return xhemidir, spherefile - - -def interhemi_projection(fsdir, sid, template_dir): - """ Surface-based features projection to the 'fsaverage_sym' atlas. - - Parameters - ---------- - fsdir: str - the FreeSurfer subjects directory 'SUBJECTS_DIR'. - sid: str - the subject identifier - template_dir: str - path to the 'fsaverage_sym' template. - - Returns - ------- - xhemi_features: dict - the different features projected to the common symmetric atlas. - """ - textures = ("thickness", "curv", "area", "pial_lgi", "sulc") - subjfsdir = os.path.join(fsdir, sid) - reg_xhemi_file = os.path.join( - subjfsdir, "xhemi", "surf", "lh.fsaverage_sym.sphere.reg") - reg_sub_file = os.path.join( - subjfsdir, "surf", "lh.fsaverage_sym.sphere.reg") - target_reg = os.path.join(template_dir, "surf", "lh.sphere.reg") - check_command("mris_apply_reg") - xhemi_features = {} - for name in textures: - xhemi_features[name] = {} - for hemi in ("lh", "rh"): - texture_file = os.path.join( - subjfsdir, "surf", "{0}.{1}".format(hemi, name)) - if not os.path.isfile(texture_file): - warnings.warn( - "Texture file not found: {}".format(texture_file), - UserWarning) - continue - if hemi == "lh": - reg_file = reg_sub_file - else: - reg_file = reg_xhemi_file - dest_texture_file = os.path.join( - subjfsdir, "surf", "{0}.{1}.xhemi.mgh".format( - hemi, name)) - cmd = ["mris_apply_reg", "--src", texture_file, - "--trg", dest_texture_file, "--streg", reg_file, - target_reg] - if os.path.isfile(dest_texture_file): - warnings.warn( - "Projected texture file already creatred: {}. Remove it " - "for regeneration.".format(dest_texture_file), - UserWarning) - else: - execute_command(cmd) - xhemi_features[name][hemi] = dest_texture_file - return xhemi_features - - -def mri_conversion(fsdir, sid): - """ Convert some modality in NiFTI format. - - Parameters - ---------- - fsdir: str - the FreeSurfer subjects directory 'SUBJECTS_DIR'. - sid: str - the subject identifier - - Returns - ------- - niifiles: dict - the converted modalities. - """ - niifiles = {} - regex = os.path.join(fsdir, sid, "mri", "{0}.mgz") - reference_file = os.path.join(fsdir, sid, "mri", "rawavg.mgz") - check_command("mri_convert") - for modality in ["aparc+aseg", "aparc.a2009s+aseg", "aseg", "wm", "rawavg", - "ribbon", "brain"]: - srcfile = regex.format(modality) - destfile = os.path.join( - fsdir, sid, "mri", "{}.nii.gz".format(modality)) - cmd = ["mri_convert", "--resample_type", "nearest", - "--reslice_like", reference_file, srcfile, destfile] - execute_command(cmd) - niifiles[modality] = destfile - return niifiles - - -def localgi(fsdir, sid): - """ Computes local measurements of pial-surface gyrification at thousands - of points over the cortical surface. - - Parameters - ---------- - fsdir: str - The FreeSurfer working directory with all the subjects. - sid: str - Identifier of subject. - - Returns - ------- - subjfsdir: str - the FreeSurfer results for the subject. - """ - # Check input parameters - subjfsdir = os.path.join(fsdir, sid) - if not os.path.isdir(subjfsdir): - raise ValueError("'{0}' FreeSurfer subject directory does not " - "exists.".format(subjfsdir)) - - # Call FreeSurfer local gyrification - check_command("recon-all") - cmd = ["recon-all", "-localGI", "-subjid", sid, "-sd", fsdir, - "-no-isrunning"] - execute_command(cmd) - - return subjfsdir - - -def stats2table(fsdir, outdir): - """ Generate text/ascii tables of freesurfer parcellation stats data - '?h.aparc.stats' for both templates (Desikan & Destrieux) and - 'aseg.stats'. - - Parameters - ---------- - fsdir: str - the FreeSurfer working directory with all the subjects. - outdir: str - the destination folder. - - Returns - ------- - statfiles: list of str - The FreeSurfer summary stats. - """ - # Check input parameters - for path in (fsdir, outdir): - if not os.path.isdir(path): - raise ValueError("'{0}' is not a valid directory.".format(path)) - - # Fist find all the subjects with a stat dir - statdirs = glob.glob(os.path.join(fsdir, "*", "stats")) - subjects = [item.lstrip(os.sep).split(os.sep)[-2] for item in statdirs] - subjects = [item for item in subjects - if item not in ("fsaverage", "fsaverage_sym")] - os.environ["SUBJECTS_DIR"] = fsdir - statfiles = [] - measures = ["area", "volume", "thickness", "thicknessstd", - "meancurv", "gauscurv", "foldind", "curvind"] - check_command("aparcstats2table") - check_command("asegstats2table") - - # Call FreeSurfer aparcstats2table: Desikan template - for hemi in ["lh", "rh"]: - for meas in measures: - statfile = os.path.join( - outdir, "aparc_stats_{0}_{1}.csv".format(hemi, meas)) - statfiles.append(statfile) - cmd = ["aparcstats2table", "--subjects"] + subjects + [ - "--hemi", hemi, "--meas", meas, "--tablefile", statfile, - "--delimiter", "comma", "--parcid-only"] - execute_command(cmd) - - # Call FreeSurfer aparcstats2table: Destrieux template - for hemi in ["lh", "rh"]: - for meas in measures: - statfile = os.path.join( - outdir, "aparc2009s_stats_{0}_{1}.csv".format(hemi, meas)) - statfiles.append(statfile) - cmd = ["aparcstats2table", "--subjects"] + subjects + [ - "--parc", "aparc.a2009s", "--hemi", hemi, "--meas", meas, - "--tablefile", statfile, "--delimiter", "comma", - "--parcid-only"] - execute_command(cmd) - - # Call FreeSurfer asegstats2table - statfile = os.path.join(outdir, "aseg_stats.csv") - statfiles.append(statfile) - cmd = ["asegstats2table", "--subjects"] + subjects + [ - "--meas", "volume", "--tablefile", statfile, "--delimiter", "comma"] - execute_command(cmd) - statfiles.append(statfile) - - return statfiles diff --git a/brainprep/datasets/__init__.py b/brainprep/datasets/__init__.py new file mode 100644 index 00000000..32ebf156 --- /dev/null +++ b/brainprep/datasets/__init__.py @@ -0,0 +1,29 @@ +########################################################################## +# NSAp - Copyright (C) CEA, 2022 - 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. +########################################################################## + +""" +Module that implements dataset fetchers. +""" + +from .ibc import ( + IBCDataset, +) +from .openms import ( + OpenMSDataset, +) +from .utils import ( + git_download, + openneuro_download, +) + +__all__ = [ + "IBCDataset", + "OpenMSDataset", + "git_download", + "openneuro_download", +] diff --git a/brainprep/datasets/ibc.py b/brainprep/datasets/ibc.py new file mode 100644 index 00000000..53eb6e08 --- /dev/null +++ b/brainprep/datasets/ibc.py @@ -0,0 +1,269 @@ +########################################################################## +# NSAp - Copyright (C) CEA, 2025 - 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. +########################################################################## + +""" +Fetcher to download IBC dataset from OpenNeuro. +""" + +import json +from pathlib import Path + +from ..typing import ( + Directory, +) +from ..utils import ( + Bunch, + print_info, +) +from .utils import ( + openneuro_download, +) + + +class IBCDataset: + """ + Individual Brain Charting (IBC) multi-modal dataset. + + This `dataset `_ + :footcite:p:`pinho2018ibc` contains Magnetic Resonance (MR) images of + 13 healthy volunteers. The first session is considered and includes T1W, + T2W, FLAIR, DWI, and task fMRI. + + Parameters + ---------- + datadir : Directory + Directory where data will be stored. + + Attributes + ---------- + _url : str + Internal URL used to fetch data. + + References + ---------- + + .. footbibliography:: + + Examples + -------- + >>> from brainprep.config import Config + >>> from pathlib import Path + >>> from brainprep.datasets import IBCDataset + >>> + >>> datadir = Path("/tmp/brainprep-data") + >>> datadir.mkdir(parents=True, exist_ok=True) + >>> dataset = IBCDataset(datadir) + >>> with Config(verbose=False): + ... data = dataset.fetch( + ... subject="01", + ... modality="anat", + ... ) + >>> data + Bunch( + description: PosixPath('...') + anat: PosixPath('...') + ) + """ + + _url: str = ( + "https://s3.amazonaws.com/openneuro.org/ds002685/" + "{key}" + ) + + def __init__( + self, + datadir: Directory) -> None: + self.datadir = Path(datadir) + self.allowed_subjects = [ + f"{str(subject).zfill(2)}" for subject in range(1, 16) + if subject not in (3, 10) + ] + self.allowed_modalities = [ + "anat", + "func", + "dwi", + ] + + def fetch( + self, + subject: str, + modality: str) -> Bunch: + """ Fetch data. + + Parameters + ---------- + subject : str + Subject identifier: ['01' - '13']. + modality : str + Modality to be fetched: 'anat', 'func' or 'dwi'. A combination + of modalitites can be specified using the '|' delimiter. + + Returns + ------- + dataset: Bunch + Fetched data path. A 'description' entry is also available. + """ + dataset = Bunch() + modalities = modality.split("|") + for mod in modalities: + self.sanity_check(subject, mod) + + description_file = ( + self.datadir / + "rawdata" / + "dataset_description.json" + ) + if not description_file.is_file(): + description_file.parent.mkdir(parents=True, exist_ok=True) + with open(description_file, "w") as of: + description = { + "Name": "IBC dataset", + "BIDSVersion": "1.0.2", + } + json.dump(description, of, indent=4) + dataset["description"] = description_file + + sidecar_t1w = { + "Modality": "MR", + "MagneticFieldStrength": 3, + "Manufacturer": "Siemens", + "ManufacturersModelName": "MAGNETOM Prisma-fit", + "MRAcquisitionType": "3D", + "SeriesDescription": "MPRAGE_SAGITTAL", + "ProtocolName": "MPRAGE", + "AcquisitionNumber": 1, + "SliceThickness": 1, + "EchoTime": 0.00298, + "RepetitionTime": 2.3, + "InversionTime": 0.9, + "FlipAngle": 9, + "PartialFourier": 0.875, + "BaseResolution": 256, + "FrequencyEncodingSteps": 256, + "PhaseEncodingStepsOutOfPlane": 176, + "ReconMatrixPE": 240, + } + mapping = [ + ("anat", + f"sub-{subject}_ses-00_T1w.nii.gz", + None), + ("anat", + sidecar_t1w, + f"sub-{subject}_ses-00_T1w.json"), + ] + if "dwi" in modalities: + mapping += [ + ("dwi", + f"sub-{subject}_ses-00_dwi.nii.gz", + None), + ("dwi", + f"sub-{subject}_ses-00_dwi.bvec", + None), + ("dwi", + f"sub-{subject}_ses-00_dwi.bval", + None), + ("dwi", + "dwi.json", + f"sub-{subject}_ses-00_dwi.json"), + ] + if "func" in modalities: + task = "ArchiStandard" + mapping += [ + ("func", + f"sub-{subject}_ses-00_task-{task}_dir-pa_bold.nii.gz", + None), + ("func", + f"task-{task}_dir-pa_bold.json", + f"sub-{subject}_ses-00_task-{task}_dir-pa_bold.json"), + ("func", + f"sub-{subject}_ses-00_task-{task}_dir-pa_sbref.nii.gz", + None), + ("func", + f"task-{task}_dir-pa_sbref.json", + f"sub-{subject}_ses-00_task-{task}_dir-pa_sbref.json"), + ("func", + f"sub-{subject}_ses-00_task-{task}_dir-pa_events.tsv", + None), + ("fmap", + f"sub-{subject}_ses-00_task-{task}_dir-ap_sbref.nii.gz", + None), + ("fmap", + f"task-{task}_dir-ap_sbref.json", + f"sub-{subject}_ses-00_task-{task}_dir-ap_sbref.json"), + ] + + to_download = [] + for dtype, srcname, dstname in mapping: + if dstname is None: + url = self._url.format( + key=( + f"sub-{subject}/" + "ses-00/" + f"{dtype if dtype != 'fmap' else 'func'}/" + f"{srcname}" + ) + ) + elif isinstance(srcname, dict): + pass + else: + url = self._url.format( + key=f"{srcname}" + ) + destination = ( + self.datadir / + "rawdata" / + f"sub-{subject}" / + "ses-00" / + dtype / + (dstname or srcname) + ) + destination.parent.mkdir(parents=True, exist_ok=True) + is_niigz = destination.suffixes == [".nii", ".gz"] + is_func_bold = (dtype == "func" and "bold" in srcname) + is_not_func = (dtype != "func") + if is_niigz and (is_func_bold or is_not_func): + dataset[dtype] = destination + elif destination.suffix.endswith(".bvec"): + dataset["bvec"] = destination + elif destination.suffix.endswith(".bval"): + dataset["bval"] = destination + if isinstance(srcname, dict): + with open(destination, "w") as of: + json.dump(srcname, of, indent=4) + else: + to_download.append((url, destination)) + + for url, destination in to_download: + if not destination.is_file(): + print_info(f"downloading: {url}") + openneuro_download(url, destination) + + return dataset + + def sanity_check( + self, + subject: str, + modality: str) -> None: + """ Check that the fetch parameters are correct. + + Parameters + ---------- + subject : str + the subject identifier. + modality : str + the modality to be fetched. + + Raises + ------ + ValueError + If the fetch input parameters are not correct. + """ + if modality not in self.allowed_modalities: + raise ValueError("Unexpected modality.") + if subject not in self.allowed_subjects: + raise ValueError("Unexpected subject.") diff --git a/brainprep/datasets/openms.py b/brainprep/datasets/openms.py new file mode 100644 index 00000000..3408ecf3 --- /dev/null +++ b/brainprep/datasets/openms.py @@ -0,0 +1,222 @@ +########################################################################## +# NSAp - Copyright (C) CEA, 2025 - 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. +########################################################################## + +""" +Fetcher to download the OpenMS dataset from GitHub. +""" + +import json +from pathlib import Path + +from ..typing import ( + Directory, +) +from ..utils import ( + Bunch, + print_info, +) +from .utils import ( + git_download, +) + + +class OpenMSDataset: + """ + Open Multiple Sclerosis (OpenMS) anatomical dataset. + + This `dataset `_ + :footcite:p:`lesjak2016data` contains + Magnetic Resonance (MR) images of Multiple Sclerosis (MS) + patients with corresponding ground truth segmentations of white matter + lesion changes. + + Parameters + ---------- + datadir : Directory + Directory where data will be stored. + + Attributes + ---------- + _url : str + Internal URL used to fetch data. + + References + ---------- + + .. footbibliography:: + + Examples + -------- + >>> from brainprep.config import Config + >>> from pathlib import Path + >>> from brainprep.datasets import OpenMSDataset + >>> + >>> datadir = Path("/tmp/brainprep-data") + >>> datadir.mkdir(parents=True, exist_ok=True) + >>> dataset = OpenMSDataset(datadir) + >>> with Config(verbose=False): + ... data = dataset.fetch( + ... subject="01", + ... modality="T1w", + ... dtype="cross_sectional", + ... ) + >>> data + Bunch( + description: PosixPath('...') + anat: PosixPath('...') + ) + """ + + _url: str = ( + "https://raw.githubusercontent.com/muschellij2/open_ms_data/refs/" + "heads/master/{dtype}/raw/patient{subject}/{basename}.nii.gz" + ) + + def __init__( + self, + datadir: Directory) -> None: + self.datadir = Path(datadir) + self.allowed_dtypes = [ + "cross_sectional", + "longitudinal", + ] + self.allowed_subjects = [ + [f"{str(subject).zfill(2)}" for subject in range(1, 31)], + [f"{str(subject).zfill(2)}" for subject in range(1, 21)], + ] + self.allowed_modalities = [ + "T1W", + "T2W", + "FLAIR", + ] + self.timepoints = [ + "study1", + "study2", + ] + + def fetch( + self, + subject: str, + modality: str, + dtype: str = "cross_sectional") -> Bunch: + """ Fetch data. + + Parameters + ---------- + subject : str + the subject identifier. This identifier must lie in ['01' - '30'], + ['01' - '20'], for cross sectional or longitudinal data + respectively. + modality : str + the modality to be fetched: 'T1w', 'T2w' or 'FLAIR'. + dtype : str + the type of data to download: 'cross_sectional' or 'longitudinal'. + Default 'cross_sectional'. + + Returns + ------- + dataset : Bunch + the fetched data path. Keys are either 'sub-{subject}' or + 'sub-{subject}_ses-{timepoint}' for cross sectional and + longitudinal data, respectively. A 'description' entry is + also available. + """ + dataset = Bunch() + self.sanity_check(subject, modality.upper(), dtype) + + description_file = ( + self.datadir / + "rawdata" / + "dataset_description.json" + ) + if not description_file.is_file(): + description_file.parent.mkdir(parents=True, exist_ok=True) + with open(description_file, "w") as of: + description = { + "Name": "OpenMS dataset", + "BIDSVersion": "1.0.2", + } + json.dump(description, of, indent=4) + dataset["description"] = description_file + + to_download = [] + if dtype == "longitudinal": + for counter, timepoint in enumerate(self.timepoints): + basename = f"{timepoint}_{modality.upper()}" + url = self._url.format( + dtype=dtype, + subject=subject, + basename=basename, + ) + destination = ( + self.datadir / + "rawdata" / + f"sub-{subject}" / + f"ses-{timepoint}" / + "anat" / + f"sub-{subject}_ses-{timepoint}_{modality}.nii.gz" + ) + destination.parent.mkdir(parents=True, exist_ok=True) + dataset[f"anat{counter + 1}"] = destination + to_download.append((url, destination)) + else: + url = self._url.format( + dtype=dtype, + subject=subject, + basename=modality.upper(), + ) + destination = ( + self.datadir / + "rawdata" / + f"sub-{subject}" / + "ses-01" / + "anat" / + f"sub-{subject}_{modality}.nii.gz" + ) + destination.parent.mkdir(parents=True, exist_ok=True) + dataset["anat"] = destination + to_download.append((url, destination)) + + for url, destination in to_download: + if not destination.is_file(): + print_info(f"downloading: {url}") + git_download(url, destination) + + return dataset + + def sanity_check( + self, + subject: str, + modality: str, + dtype: str = "cross_sectional") -> None: + """ Check that the fetch parameters are correct. + + Parameters + ---------- + subject : str + the subject identifier. This identifier must lie in ['01' - '30'], + ['01' - '20'], for cross sectional or longitudinal data + respectively. + modality : str + the modality to be fetched: 'T1w', 'T2w' or 'FLAIR'. + dtype : str + the type of data to download: 'cross_sectional' or 'longitudinal'. + Default 'cross_sectional' + + Raises + ------ + ValueError + If the fetch input parameters are not correct. + """ + if dtype not in self.allowed_dtypes: + raise ValueError("Unexpected data type.") + if modality not in self.allowed_modalities: + raise ValueError("Unexpected modality.") + index = self.allowed_dtypes.index(dtype) + if subject not in self.allowed_subjects[index]: + raise ValueError("Unexpected subject.") diff --git a/brainprep/datasets/utils.py b/brainprep/datasets/utils.py new file mode 100644 index 00000000..9ff362de --- /dev/null +++ b/brainprep/datasets/utils.py @@ -0,0 +1,83 @@ +########################################################################## +# NSAp - Copyright (C) CEA, 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. +########################################################################## + +""" +Dataset creation tools. +""" + +from pathlib import Path + +import requests + + +def git_download( + url: str, + destination: Path) -> None: + """ + Download data from GitHub URL and saves it locally. + + Parameters + ---------- + url: str + Direct URL to the raw data file on GitHub. + destination: Path + Path to the saved data file. + + Raises + ------ + ValueError + If the URL does not point to 'raw.githubusercontent.com'. + """ + # Ensure it's a raw GitHub URL + if "raw.githubusercontent.com" not in url: + raise ValueError( + f"URL '{url}' does not point to 'raw.githubusercontent.com'." + ) + + # Download the data + response = requests.get(url) + response.raise_for_status() + + # Save the data + with destination.open("wb") as of: + of.write(response.content) + del response + + +def openneuro_download( + url: str, + destination: Path) -> None: + """ + Download data from OpenNeuro URL and saves it locally. + + Parameters + ---------- + url: str + Direct URL to the data file on OpenNeuro. + destination: Path + Path to the saved data file. + + Raises + ------ + ValueError + If the URL does not point to 's3.amazonaws.com/openneuro.org'. + """ + # Ensure it's a raw GitHub URL + if "s3.amazonaws.com/openneuro.org" not in url: + raise ValueError( + f"URL '{url}' does not point to 's3.amazonaws.com/openneuro.org'." + ) + + # Download the data + response = requests.get(url) + response.raise_for_status() + + # Save the data + with destination.open("wb") as of: + of.write(response.content) + del response diff --git a/brainprep/decorators.py b/brainprep/decorators.py new file mode 100644 index 00000000..b9633f2c --- /dev/null +++ b/brainprep/decorators.py @@ -0,0 +1,1050 @@ +########################################################################## +# 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. +########################################################################## + +""" +Defines a hook-driven decorator step with common hooks. +""" + +import datetime +import inspect +import json +import platform +import subprocess +from collections.abc import Callable, Iterable +from pathlib import Path +from typing import ( + Any, +) + +from decorator import decorator + +from ._version import __version__ +from .config import ( + DEFAULT_OPTIONS, + brainprep_options, +) +from .reporting import ( + RSTReport, + trace_module_calls, +) +from .typing import ( + Directory, + File, +) +from .utils import ( + Bunch, + coerce_to_list, + coerce_to_path, + parse_bids_keys, + print_command, + print_title, +) +from .wrappers import ( + check_outputs, + is_list_list_str, + is_list_str, + run_command, +) + + +class Hook: + """ + Base class for decorator hooks. + + Hooks used with the decorator must inherit from this class and + and optionally override ``before_call`` and ``after_call`` methods. + These methods act as hooks that run before and after the wrapped function + is executed. + + By default, both methods implement pass-through behavior: + ``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 + overridden, the default implementation simply returns its argument + unchanged. + """ + + def before_call( + self, + func: Callable, + inputs: dict[str, Any], + ) -> dict[str, Any]: + """Transform and inspect inputs before the function call.""" + return inputs + + def after_call( + self, + outputs: Any, + ) -> Any: + """Transform and inspect outputs after the function call.""" + return outputs + + +class PythonWrapperHook(Hook): + """ + Perform Python wrapper-specific preprocessing and output validation. + + This hook provides two core features: + + 1. Automatically injects a `dryrun` argument. + 2. Recursively validates all generated output files. + + Raises + ------ + ValueError + If invalid wrapper type is specified. + + Examples + -------- + >>> from brainprep.decorators import step, PythonWrapperHook + >>> from brainprep.config import Config + + >>> @step( + ... hooks=[PythonWrapperHook()] + ... ) + ... def ls_command(my_dir, dryrun=False): + ... if dryrun: + ... return None + ... return os.listdir(my_dir) + + >>> with Config(dryrun=True): + ... ls_command("/tmp") + """ + + def before_call( + self, + func: Callable, + inputs: dict[str, Any], + ) -> dict[str, Any]: + """ + Transform and inspect inputs before the function call. + + Parameters + ---------- + func : Callable + The function to be decorated. + inputs : dict[str, Any] + Positional and keyword arguments passed to `func`. + + Returns + ------- + inputs : dict[str, Any] + Positional and keyword arguments passed to `func` with the `dryrun` + parameter set. + + Raises + ------ + ValueError + If the decorated function have no `dryrun` keyword argument. + """ + opts = brainprep_options.get() + verbose = opts.get("verbose", DEFAULT_OPTIONS["verbose"]) + dryrun = opts.get("dryrun", DEFAULT_OPTIONS["dryrun"]) + + if "dryrun" not in inputs: + raise ValueError( + "This decorator needs a 'dryrun' function argument." + ) + + inputs["dryrun"] = dryrun + + return inputs + + def after_call( + self, + outputs: File | tuple[File] | None, + ) -> File | tuple[File] | None: + """ + Transform and inspect outputs after the function call. + + This method inspects the output returned by the decorated function, + recursively validates each file path, and normalizes the return type: + + - ``None`` is returned unchanged. + - A single-file output is returned as a ``File``. + - Multiple files are returned as a ``tuple[File]``. + + Parameters + ---------- + outputs : File | tuple[File] | None + The output file or tuple of files returned by the decorated + function, or ``None`` if no output was produced. + + Returns + ------- + outputs : File | tuple[File] | None + The validated output. + """ + opts = brainprep_options.get() + verbose = opts.get("verbose", DEFAULT_OPTIONS["verbose"]) + dryrun = opts.get("dryrun", DEFAULT_OPTIONS["dryrun"]) + + for item in outputs or []: + check_outputs(item, dryrun, verbose) + + if outputs is None: + return None + elif len(outputs) == 1: + return outputs[0] + else: + return tuple(outputs) + + return outputs + + +class CommandLineWrapperHook(Hook): + """ + Perform command line wrapper-specific preprocessing and output validation. + + This Hook provides two core features: + + 1. Execute command-line operations in normal (non-dry run) mode. + 2. Recursively validates all generated output files. + + Examples + -------- + >>> from brainprep.decorators import step, CommandLineWrapperHook + >>> from brainprep.config import Config + + >>> @step( + ... hooks=[CommandLineWrapperHook()] + ... ) + ... def ls_command(my_dir): + ... return ["ls", my_dir] + + >>> with Config(dryrun=True): + ... ls_command("/tmp") + [command] - ls /tmp + """ + + def after_call( + self, + outputs: File | tuple[File] | None, + ) -> File | tuple[File] | None: + """ + Transform and inspect outputs after the function call. + + This method executes command-line operations in normal (non-dry run) + mode, inspects the output returned by the decorated function, + recursively validates each file path, and normalizes the return type: + + - ``None`` is returned unchanged. + - A single-file output is returned as a ``File``. + - Multiple files are returned as a ``tuple[File]``. + + Parameters + ---------- + outputs : File | tuple[File] | None + The output file or tuple of files returned by the decorated + function, or ``None`` if no output was produced. + + Returns + ------- + outputs : File | tuple[File] | None + The validated output. + + Raises + ------ + ValueError + If the decorated function does not return a valid command + specification. + """ + opts = brainprep_options.get() + verbose = opts.get("verbose", DEFAULT_OPTIONS["verbose"]) + dryrun = opts.get("dryrun", DEFAULT_OPTIONS["dryrun"]) + + return_values = outputs + if isinstance(return_values, list): + command, outputs = return_values, None + elif isinstance(return_values, tuple) and len(return_values) == 2: + command, outputs = return_values + else: + raise ValueError( + "The decorated function must return either a command list, " + "or a tuple of (command list, (output files, ))." + ) + + if not is_list_str(command) and not is_list_list_str(command): + raise ValueError( + "Invalid command format: expected a list of strings or a " + "list of list of string for multiple commands." + ) + commands = [command] if is_list_str(command) else command + for cmd in commands: + print_command(" ".join(cmd)) + + if not dryrun: + for cmd in commands: + run_command(cmd) + + for item in outputs or []: + check_outputs(item, dryrun, verbose) + + if outputs is None: + return None + elif len(outputs) == 1: + return outputs[0] + else: + return tuple(outputs) + + return outputs + + +class BidsHook(Hook): + """ + BIDS specification. + + This hook performs three main tasks: + + 1. Compute a BIDS-compliant output directory path based on the input + BIDS file(s) and inject it into the function inputs. + + 2. Ensure BIDS-compliant metadata is written to the output directory. + + 3. Inject the ``entities`` parameter into the function when a + ``bids_file`` argument is provided. + + Parameters + ---------- + process : str | None + Name of the processing pipeline (e.g., 'fmriprep', 'custom'). Default + None. + bids_file : str | None + Name of the argument in the function that contains the BIDS file path. + or iterable of file path. Default None. + container : str | None + The name of the container (e.g., Docker image) used to run the + pipeline. Default None. + add_subjects : bool + If True, add a 'subjects' upper level directory in the output + directory, for instance to regroup subject level data. Default False. + longitudinal : bool + If True, add a 'longitudinal' upper level directory in the output + directory. Default False. + + Examples + -------- + >>> from typing import Any + >>> from brainprep.decorators import step, BidsHook, CoerceparamsHook + >>> from brainprep.typing import File, Directory + >>> from brainprep.utils import Bunch + + >>> @step( + ... hooks=[ + ... CoerceparamsHook(), + ... BidsHook( + ... process="test", + ... bids_file="t1_file", + ... add_subjects=True, + ... ), + ... ] + ... ) + ... def myfunc(t1_file: File, output_dir: Directory, **kwargs: Any): + ... '''BIDS specification.''' + ... entities = kwargs.get("entities", {}) + ... return Bunch( + ... t1_file=t1_file, + ... output_dir=output_dir, + ... entities=entities, + ... ) + + >>> result = myfunc( + ... "/tmp/rawdata/sub-00/anat/sub-00_run-00_T1w.nii.gz", + ... "/tmp/derivatives", + ... ) + >>> print(result) + Bunch( + t1_file: PosixPath('/tmp/rawdata/sub-00/anat/sub-00_run-00_T1w.nii.gz') + output_dir: PosixPath('...derivatives/test/subjects/sub-00/ses-01') + entities: {'sub': '00', 'run': '00', ...} + ) + """ + + def __init__( + self, + process: str | None = None, + bids_file: str | None = None, + container: str | None = None, + add_subjects: bool = False, + longitudinal: bool = False, + ) -> None: + self.process = process + self.bids_file = bids_file + self.container = container + self.add_subjects = add_subjects + self.longitudinal = longitudinal + + def before_call( + self, + func: Callable, + inputs: dict[str, Any], + ) -> dict[str, Any]: + """ + Transform and inspect inputs before the function call. + + Parameters + ---------- + func : Callable + The function to be decorated. + inputs : dict[str, Any] + Positional and keyword arguments passed to `func`. + + Returns + ------- + inputs : dict[str, Any] + Positional and keyword arguments passed to `func` with adjusted + 'output_dir' injected. + + Raises + ------ + ValueError + If the decorated function has no `bids_file` or `output_dir` + arguments. + If `kwargs` argument is not a `dict`. + """ + if self.process is None: + return inputs + + for key in (self.bids_file, "output_dir"): + if key is not None and key not in inputs: + raise ValueError( + f"The 'bids' hook needs a '{key}' function " + "argument." + ) + if "kwargs" in inputs and not isinstance(inputs["kwargs"], dict): + raise ValueError( + "The 'kwargs' argument needs to be a 'dict'." + ) + + subject_level = self.bids_file is not None + output_dir = ( + Path(inputs["output_dir"]) / + "derivatives" / + self.process + ) + if self.longitudinal: + output_dir /= "longitudinal" + if self.add_subjects: + output_dir /= "subjects" + if subject_level: + if isinstance(inputs[self.bids_file], (list, tuple)): + entities = [ + parse_bids_keys( + path, + check_run=True, + ) + for path in inputs[self.bids_file] + ] + entities_ = entities[0] + else: + entities_ = entities = parse_bids_keys( + inputs[self.bids_file], + check_run=True, + ) + output_dir = ( + output_dir / + f"sub-{entities_['sub']}" / + f"ses-{entities_['ses']}" + ) + if "kwargs" in inputs: + inputs["kwargs"]["entities"] = entities + metadata_file = ( + Path(inputs["output_dir"]) / + "derivatives" / + self.process / + "dataset_description.json" + ) + + metadata_file.parent.mkdir(parents=True, exist_ok=True) + inputs["output_dir"] = output_dir + + if not metadata_file.is_file(): + metadata = { + "Name": f"{func.__module__}.{func.__name__}", + "BIDSVersion": "1.8.0", + "DatasetType": "derivative", + "GeneratedBy": [ + { + "Name": "brainprep", + "Version": __version__, + "CodeURL": ("https://github.com/brainprepdesk/" + "brainprep"), + } + ], + } + if self.container is not None: + metadata["GeneratedBy"][0].update( + { + "Container": { + "Type": "docker", + "Tag": f"{self.container}:{__version__}" + } + } + ) + with metadata_file.open("w", encoding="utf-8") as of: + json.dump(metadata, of, indent=4) + + return inputs + + +class CoerceparamsHook(Hook): + """ + Convert annotated arguments. + + This hook inspects the type annotations of the wrapped function + and performs two automatic conversions: + + 1. Arguments annotated as ``File`` or ``Directory`` are converted + into ``pathlib.Path`` instances. + + 2. Arguments annotated as list types (e.g., ``list[int]``, + ``list[str]``) are parsed from comma-separated strings into + Python lists. + + Examples + -------- + >>> from brainprep.decorators import step, CoerceparamsHook + >>> from brainprep.typing import Directory + >>> from brainprep.utils import Bunch + + >>> @step( + ... hooks=[CoerceparamsHook()] + ... ) + ... def myfunc(a: Directory, b: str, c: list[Directory]): + ... '''Convert annotated arguments.''' + ... return Bunch(a=a, b=b, c=c) + + >>> result = myfunc("/tmp", "/tmp", "/tmp,/tmp") + >>> print(result) + Bunch( + a: PosixPath('/tmp') + b: '/tmp' + c: [PosixPath('/tmp'), PosixPath('/tmp')] + ) + """ + + def before_call( + self, + func: Callable, + inputs: dict[str, Any], + ) -> dict[str, Any]: + """ + Transform and inspect inputs before the function call. + + Parameters + ---------- + func : Callable + The function to be decorated. + inputs : dict[str, Any] + Positional and keyword arguments passed to `func`. + + Returns + ------- + inputs : dict[str, Any] + Positional and keyword arguments passed to `func` where arguments + annotated as ``File`` or ``Directory`` are converted to + ``pathlib.Path`` objects, and list-typed arguments are coerced from + comma-separated strings into lists. + + Raises + ------ + ValueError + If the decorated function contains arguments without type + annotations. + """ + sig = inspect.signature(func) + + for name, param in sig.parameters.items(): + if param.annotation is inspect.Parameter.empty: + raise ValueError( + "The decorated function must only have typed arguments." + ) + inputs[name] = coerce_to_path( + coerce_to_list( + inputs[name], + param.annotation, + ), + param.annotation, + ) + + return inputs + + +class OutputdirHook(Hook): + """ + Fill and create the output directory. + + This hook ensures that the output directory exists before the + wrapped function is executed. Optional subdirectories can also be + created, such as a ``figures`` directory for plots or a ``quality_check`` + directory for quality check outputs or ``morphometry`` directory + for morphometry outputs. + + Parameters + ---------- + plotting : bool + If True, add a ``figures`` upper level directory in the output + directory. Default False. + quality_check : bool + If True, add a ``quality_check`` upper level directory in the output + directory. Default False. + morphometry : bool + If True, add a ``morphometry`` upper level directory in the output + directory. Default False. + + Examples + -------- + >>> from brainprep.decorators import step, OutputdirHook + >>> from brainprep.typing import Directory + >>> from brainprep.utils import Bunch + + >>> @step( + ... hooks=[OutputdirHook(plotting=True)] + ... ) + ... def myfunc(output_dir: Directory): + ... '''Fill and create the output directory.''' + ... return Bunch(output_dir=output_dir) + + >>> result = myfunc("/tmp") + >>> print(result) + Bunch( + output_dir: PosixPath('/tmp/figures') + ) + """ + + def __init__( + self, + plotting: bool = False, + quality_check: bool = False, + morphometry: bool = False, + ) -> None: + self.plotting = plotting + self.quality_check = quality_check + self.morphometry = morphometry + + def before_call( + self, + func: Callable, + inputs: dict[str, Any], + ) -> dict[str, Any]: + """ + Transform and inspect inputs before the function call. + + Parameters + ---------- + func : Callable + The function to be decorated. + inputs : dict[str, Any] + Positional and keyword arguments passed to `func`. + + Returns + ------- + inputs : dict[str, Any] + Positional and keyword arguments passed to `func` where the + `output_dir`` argument has been updated and created. + + Raises + ------ + ValueError + If the decorated function has no ``output_dir`` argument. + """ + if "output_dir" not in inputs: + raise ValueError( + "The decorated function needs a 'output_dir' argument." + ) + + output_dir = Path(inputs["output_dir"]) + if self.plotting: + inputs["output_dir"] = ( + output_dir / + "figures" + ) + if self.quality_check: + inputs["output_dir"] = ( + output_dir / + "quality_check" + ) + if self.morphometry: + inputs["output_dir"] = ( + output_dir / + "morphometry" + ) + + inputs["output_dir"].mkdir(parents=True, exist_ok=True) + + return inputs + + +class LogRuntimeHook(Hook): + """ + Decorator that logs runtime metadata and input/output details of a + function call. + + This decorator uses an `RSTReport` instance to record metadata about the + execution of the decorated function, including its module, docstring, + inputs, outputs, and runtime statistics such as execution time and system + information. + + 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: + + - the function's name, module, and docstring + - the input arguments passed to the function + - the returned output value + - runtime statistics (e.g., execution time) + - system information relevant to reproducibility + + The collected metadata is appended to the report, allowing the workflow + engine to generate detailed execution summaries suitable for provenance + tracking, debugging, or documentation. + + Parameters + ---------- + title : str | None + A title to display. Default None. + bunched : bool + Return a bunch object with a default 'outputs' key. Default True. + + Notes + ----- + - The report is created using `RSTReport(reloadable=True)`, which allows + tracking multiple decorated steps. + - Inputs are captured using `inspect.getcallargs`. + - Runtime metadata includes start and end timestamps, execution duration + in hours, platform details, and hostname. + - Current configuration is also captured. + + Examples + -------- + >>> from brainprep.reporting import RSTReport + >>> from brainprep.decorators import step, LogRuntimeHook + + >>> @step( + ... hooks=[LogRuntimeHook()] + ... ) + ... def add(a, b): + ... '''Adds two numbers.''' + ... return a + b + + >>> report = RSTReport() + >>> result = add(3, 5) + >>> print(report) + Bunch( + step1: Bunch( + module: '...add' + description: 'Adds two numbers.' + inputs: Bunch( + a: 3 + b: 5 + ) + outputs: Bunch( + outputs: 8 + ) + runtime: Bunch( + start: '...' + end: '...' + execution_time: ... + brainprep_version: '...' + platform: '...' + hostname: '...' + ) + config: Bunch( + ... + ) + ) + """ + + def __init__( + self, + title: str | None = None, + bunched: bool = True, + ) -> None: + self.title = title + self.bunched = bunched + + def before_call( + self, + func: Callable, + inputs: dict[str, Any], + ) -> dict[str, Any]: + """ + Transform and inspect inputs before the function call. + + Parameters + ---------- + func : Callable + The function to be decorated. + inputs : dict[str, Any] + Positional and keyword arguments passed to `func`. If a + `report_file` keyword argument is passed, the logged runtime metada + are saved in this file. + + Returns + ------- + inputs : dict[str, Any] + Positional and keyword arguments passed to `func` where arguments + annotated as ``File`` or ``Directory`` are converted to + ``pathlib.Path`` objects, and list-typed arguments are coerced from + comma-separated strings into lists. + """ + report = RSTReport( + reloadable=True, + increment=True, + ) + if self.title is not None: + print_title(f"{self.title}...") + trace = trace_module_calls() + self.identifier = f"step{report._count}" + report.register( + self.identifier, "module", f"{func.__module__}.{func.__name__}" + ) + report.register(self.identifier, "description", func.__doc__ or "") + if trace: + report.register(self.identifier, "trace", trace) + report.register(self.identifier, "inputs", Bunch(**inputs)) + self.start = datetime.datetime.now() + return inputs + + def after_call( + self, + outputs: Any, + ) -> Any: + """ + Transform and inspect outputs after the function call. + """ + report = RSTReport( + reloadable=True, + increment=False, + ) + outputs_ = ( + Bunch(outputs=outputs) + if not isinstance(outputs, Bunch) + else outputs + ) + self.end = datetime.datetime.now() + report.register(self.identifier, "outputs", outputs_) + if self.bunched: + outputs = outputs_ + runtime = Bunch( + start=str(self.start), + end=str(self.end), + execution_time=(self.end - self.start).total_seconds() / 3600, + brainprep_version=__version__, + platform=platform.platform(), + hostname=platform.node(), + ) + report.register(self.identifier, "runtime", runtime) + config = Bunch(**DEFAULT_OPTIONS.copy()) + config.update( + brainprep_options.get() + ) + report.register(self.identifier, "config", config) + if self.title is not None: + print_title(f"{self.title} done.") + return outputs + + +class SaveRuntimeHook(Hook): + """ + Decorator that save logged runtime metadata in a 'output_dir/log' folder. + + Parameters + ---------- + parent : bool + If True, logs will be saved in the parent output directory instead of + the output directory. This is useful when centralizing log files. + Default False. + + Examples + -------- + >>> from brainprep.decorators import ( + ... step, LogRuntimeHook, SaveRuntimeHook + ... ) + + >>> @step( + ... hooks=[ + ... LogRuntimeHook(), + ... SaveRuntimeHook(), + ... ] + ... ) + ... def add(a, b, output_dir="/tmp"): + ... '''Adds two numbers.''' + ... return a + b + + >>> result = add(3, 5) + """ + + def __init__( + self, + parent: bool = False, + ) -> None: + self.parent = parent + + def before_call( + self, + func: Callable, + inputs: dict[str, Any] + ) -> dict[str, Any]: + """ + Transform and inspect inputs before the function call. + + Parameters + ---------- + func : Callable + The function to be decorated. + inputs : dict[str, Any] + Positional and keyword arguments passed to `func`. If a + `report_file` keyword argument is passed, the logged runtime metada + are saved in this file. + + Returns + ------- + inputs : dict[str, Any] + Positional and keyword arguments passed to `func` (unchanged). + + Raises + ------ + ValueError + If the `output_dir` keyword argument is not defined. + """ + if "output_dir" not in inputs: + raise ValueError( + "This decorator needs an 'output_dir' function argument." + ) + self.output_dir = Path(inputs["output_dir"]) + return inputs + + def after_call( + self, + outputs: Any, + ) -> Any: + """ + Transform and inspect outputs after the function call. + """ + report = RSTReport( + reloadable=True, + increment=False, + ) + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + if self.parent: + report_file = ( + self.output_dir.parent / + "log" / + f"report_{timestamp}.rst" + ) + else: + report_file = ( + self.output_dir / + "log" / + f"report_{timestamp}.rst" + ) + report_file.parent.mkdir(parents=True, exist_ok=True) + report.save_as_rst(report_file) + return outputs + + +@decorator +def step( + func: Callable, + hooks: Iterable[Hook] | None = None, + *args: Any, + **kw: Any, + ) -> Any: + """ + Execute a function within a hook-driven workflow. + + This decorator wraps a function and applies a sequence of hooks + that may transform the function's inputs before execution and its + outputs afterward. Each hook must implement the ``before_call`` and + ``after_call`` methods defined in the :class:`Hook` base class. + + The workflow proceeds in three stages: + + 1. **Input resolution** + The function's positional and keyword arguments are resolved + into a dictionary. + + 2. **Prepare phase** + Each hook's ``before_call`` method is called in the order provided. + Hooks may modify the input dictionary, which is then passed to + the next hook. + + 3. **Function execution** + The wrapped function is called with the (possibly modified) + inputs. + + 4. **Finalize phase** + Each hook's ``after_call`` method is called in the order provided. + Hooks may modify the output value, which is then passed to the + next hook. + + Parameters + ---------- + func : Callable + The function being decorated. + hooks : Iterable[Hook] | None + A sequence of hook objects. + *args : Any + Positional arguments passed to ``func``. + **kw : Any + Keyword arguments passed to ``func``. + + Returns + ------- + Any + The (possibly transformed) output of ``func`` after all + hook ``after_call`` hooks have been applied. + + Raises + ------ + TypeError + If ``hooks`` is not iterable or any element in ``hooks`` does + not inherit from :class:`Hook`. + + Notes + ----- + The decorator uses ``inspect.getcallargs`` to resolve the + function's signature into a dictionary of named arguments. + Hooks may freely modify this dictionary to influence the + function call. + """ + if not isinstance(hooks, Iterable) or isinstance(hooks, (str, bytes)): + raise TypeError( + "'hooks' must be an iterable of Hook instances, " + f"got {hooks}" + ) + for plug in hooks: + if not isinstance(plug, Hook): + raise TypeError( + f"Invalid hook {plug}: all hooks must inherit from " + "Hook" + ) + inputs = inspect.getcallargs(func, *args, **kw) + for plug in hooks: + inputs = plug.before_call(func, inputs) + kwargs = inputs.pop("kwargs", {}) + outputs = func(**inputs, **kwargs) + for plug in hooks: + outputs = plug.after_call(outputs) + return outputs diff --git a/brainprep/deface.py b/brainprep/deface.py deleted file mode 100644 index e365f80e..00000000 --- a/brainprep/deface.py +++ /dev/null @@ -1,85 +0,0 @@ -# -*- coding: utf-8 -*- -########################################################################## -# NSAp - Copyright (C) CEA, 2021 -# 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. -########################################################################## - - -""" -Common defacing functions. -""" - -# Imports -import os -from .utils import check_command, execute_command - - -def deface(anat_file, outdir): - """ Deface MRI head images using the FSL **fsl_deface** command. - - The UK Biobank study uses a customized image processing pipeline based - on FSL Alfaro-Almagro et al. (2018), which includes a de-facing - approach also based on FSL tools. It was designed for use with - T1w images. This de-facing approach was later extracted from the larger - processing pipeline and released as part of the main FSL package as - **fsl_deface**. - Like **mri_deface** and **pydeface**, this method uses linear - registration (also FLIRT) to locate its own pre-defined mask of face - voxels on the target image, then sets voxels in the mask to zero. Unlike - **mri_deface** and **pydeface**, this method also removes the ears. - Although it is also relatively popular, we did not include **mask_face** - Milchenko and Marcus (2013) because previous work has already - demonstrated that it provides inadequate protection - Abramian and Eklund (2019). - - References - ---------- - Christopher G. Schwarz, Walter K. Kremers, Heather J. Wiste, Jeffrey L. - Gunter, Prashanthi Vemuri, Anthony J. Spychalla, Kejal Kantarci, Aaron P. - Schultz, Reisa A. Sperling, David S. Knopman, Ronald C. Petersen, - Clifford R. Jack, Changing the face of neuroimaging research: Comparing - a new MRI de-facing technique with popular alternatives, NeuroImage 2021. - - Parameters - ---------- - anat_file: str - input MRI T1w head image to be defaced: need to be named as - ***T1w.**. - outdir: str - the output folder. - - Returns - ------- - defaced_anat_file: str - the defaced input MRI head image. - defaced_mask_file: str - the defacing binary mask. - """ - # Check input parameters - basename = os.path.basename(anat_file).split(".")[0] - mod = basename.split("_")[-1] - if not mod.endswith("T1w"): - raise ValueError("The input anatomical file must be a T1w image named " - "as '*T1w.'.") - - # Call FSL reorient2std - outdir = os.path.abspath(outdir) - _basename = basename.replace("T1w", "space-RAS_mod-T1w") - reo_file = os.path.join(outdir, _basename + ".nii.gz") - cmd_reorient = ["fslreorient2std", anat_file, reo_file] - check_command("fslreorient2std") - execute_command(cmd_reorient) - - # Call FSL defacing - deface_file = os.path.join(outdir, basename + ".nii.gz") - _basename = basename.replace("T1w", "mod-T1w_defacemask") - mask_file = os.path.join(outdir, _basename + ".nii.gz") - snap_pattern = os.path.join(outdir, basename + "_snapshot") - cmd = ["fsl_deface", reo_file, deface_file, "-d", mask_file, "-f", "0.5", - "-B", "-p", snap_pattern] - check_command("fsl_deface") - execute_command(cmd) - return deface_file, mask_file diff --git a/brainprep/info.py b/brainprep/info.py deleted file mode 100644 index c2069cc5..00000000 --- a/brainprep/info.py +++ /dev/null @@ -1,78 +0,0 @@ -# -*- coding: utf-8 -*- -########################################################################## -# NSAp - Copyright (C) CEA, 2021 -# 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. -########################################################################## - -# Module current version -version_major = 0 -version_minor = 0 -version_micro = 1 - -# Expected by setup.py: string of form "X.Y.Z" -__version__ = "{0}.{1}.{2}".format(version_major, version_minor, version_micro) - -# Expected by setup.py: the status of the project -CLASSIFIERS = ["Development Status :: 5 - Production/Stable", - "Environment :: Console", - "Environment :: X11 Applications :: Qt", - "Operating System :: OS Independent", - "Programming Language :: Python", - "Topic :: Scientific/Engineering", - "Topic :: Utilities"] - -# Project descriptions -description = """ -Package that provides tools for brain MRI Deep Learning pre-processing. -""" -SUMMARY = """ -.. container:: summary-carousel - - `brainprep` is a toolbox that provides common Deep Learning brain - MRI pre-processing workflows. -""" -long_description = ( - "Package that provides tools for brain MRI Deep Learning " - "pre-processing.\n") - -# Main setup parameters -NAME = "brainprep" -ORGANISATION = "CEA" -MAINTAINER = "Antoine Grigis" -MAINTAINER_EMAIL = "antoine.grigis@cea.fr" -DESCRIPTION = description -LONG_DESCRIPTION = long_description -EXTRANAME = "NeuroSpin webPage" -EXTRAURL = ( - "https://joliot.cea.fr/drf/joliot/Pages/Entites_de_recherche/" - "NeuroSpin.aspx") -LINKS = {"deepinsight": "https://github.com/neurospin-deepinsight/deepinsight"} -URL = "https://github.com/neurospin-deepinsight/brainprep" -DOWNLOAD_URL = "https://github.com/neurospin-deepinsight/brainprep" -LICENSE = "CeCILL-B" -AUTHOR = """ -brainprep developers -""" -AUTHOR_EMAIL = "antoine.grigis@cea.fr" -PLATFORMS = "OS Independent" -ISRELEASE = True -VERSION = __version__ -PROVIDES = ["brainprep"] -REQUIRES = [ - "numpy", - "nibabel", - "pandas", - "scikit-learn", - "nilearn>=0.9.2", - "matplotlib", - "seaborn", - "requests", - "progressbar2", - "fire" -] -SCRIPTS = [ - "brainprep/scripts/brainprep" -] diff --git a/brainprep/interfaces/__init__.py b/brainprep/interfaces/__init__.py new file mode 100644 index 00000000..929397e7 --- /dev/null +++ b/brainprep/interfaces/__init__.py @@ -0,0 +1,121 @@ +########################################################################## +# NSAp - Copyright (C) CEA, 2022 - 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. +########################################################################## + +""" +Module that implements interfaces. +""" + +from .ants import ( + biasfield, +) +from .cat12 import ( + cat12vbm_morphometry, + cat12vbm_wf, + write_catbatch, +) +from .fmriprep import ( + fmriprep_wf, + func_vol_connectivity, +) +from .freesurfer import ( + brainmask, + freesurfer_command_status, + freesurfer_features_summary, + freesurfer_tissues, + fsaveragesym_projection, + fsaveragesym_surfreg, + localgi, + mgz_to_nii, + nextbrain, + reconall, + reconall_longitudinal, +) +from .fsl import ( + affine, + applyaffine, + applymask, + deface, + reorient, + scale, +) +from .mriqc import ( + group_level_qa, + subject_level_qa, +) +from .plotting import ( + plot_brainparc, + plot_defacing_mosaic, + plot_histogram, + plot_network, + plot_pca, +) +from .qualcheck import ( + euler_numbers, + fmriprep_metrics, + incremental_pca, + mask_overlap, + mean_correlation, + mriqc_metrics, + network_entropy, + vbm_metrics, +) +from .utils import ( + anonfile, + copyfiles, + maskdiff, + movedir, + ungzfile, + write_uuid_mapping, +) + +__all__ = [ + "affine", + "anonfile", + "applyaffine", + "applymask", + "biasfield", + "brainmask", + "cat12vbm_morphometry", + "cat12vbm_wf", + "copyfiles", + "deface", + "euler_numbers", + "fmriprep_metrics", + "fmriprep_wf", + "freesurfer_command_status", + "freesurfer_features_summary", + "freesurfer_tissues", + "fsaveragesym_projection", + "fsaveragesym_surfreg", + "func_vol_connectivity", + "group_level_qa", + "incremental_pca", + "localgi", + "mask_overlap", + "maskdiff", + "mean_correlation", + "mgz_to_nii", + "movedir", + "mriqc_metrics", + "network_entropy", + "nextbrain", + "plot_brainparc", + "plot_defacing_mosaic", + "plot_histogram", + "plot_network", + "plot_pca", + "reconall", + "reconall_longitudinal", + "reorient", + "scale", + "subject_level_qa", + "ungzfile", + "vbm_metrics", + "write_catbatch", + "write_uuid_mapping", +] diff --git a/brainprep/interfaces/ants.py b/brainprep/interfaces/ants.py new file mode 100644 index 00000000..7bea45e0 --- /dev/null +++ b/brainprep/interfaces/ants.py @@ -0,0 +1,83 @@ +########################################################################## +# 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. +########################################################################## + + +""" +ANTs functions. +""" + +from ..decorators import ( + CoerceparamsHook, + CommandLineWrapperHook, + LogRuntimeHook, + OutputdirHook, + step, +) +from ..typing import ( + Directory, + File, +) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + bunched=False + ), + CommandLineWrapperHook(), + ] +) +def biasfield( + image_file: File, + mask_file: File, + output_dir: Directory, + entities: dict) -> tuple[list[str], tuple[File]]: + """ + Bias field correction of a BIDS-compliant anatomical image using ANTs's + `N4BiasFieldCorrection`. + + Parameters + ---------- + image_file : File + Path to the input image file. + mask_file: File + Path to a binary brain mask file. + 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] + Bias field correction command-line. + outputs : tuple[File] + - bc_image_file : File - The bias corrected input image file. + - bc_field_file : File - The estimated bias field. + """ + basename = "sub-{sub}_ses-{ses}_run-{run}_mod-{mod}".format( + **entities) + bc_image_file = output_dir / f"{basename}_biascorrected.nii.gz" + bc_field_file = output_dir / f"{basename}_biasfield.nii.gz" + + command = [ + "N4BiasFieldCorrection", + "-d", "3", + "-i", str(image_file), + "-s", "1", + "-b", "[1x1x1,3]", + "-c", "[50x50x50x50,0.001]", + "-t", "[0.15,0.01,200]", + "-x", str(mask_file), + "-o", f"[{bc_image_file},{bc_field_file}]", + "-v", + ] + + return command, (bc_image_file, bc_field_file, ) diff --git a/brainprep/interfaces/cat12.py b/brainprep/interfaces/cat12.py new file mode 100644 index 00000000..56cea40b --- /dev/null +++ b/brainprep/interfaces/cat12.py @@ -0,0 +1,384 @@ +########################################################################## +# 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. +########################################################################## + + +""" +CAT12 functions. +""" + +import glob +from pathlib import Path + +import pandas as pd +from scipy.io import loadmat + +from ..config import ( + DEFAULT_OPTIONS, + brainprep_options, +) +from ..decorators import ( + CoerceparamsHook, + CommandLineWrapperHook, + LogRuntimeHook, + OutputdirHook, + PythonWrapperHook, + step, +) +from ..typing import ( + Directory, + File, +) +from ..utils import ( + coerce_to_path, + parse_bids_keys, +) +from .utils import ( + ungzfile, +) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + bunched=False + ), + CommandLineWrapperHook(), + ] +) +def cat12vbm_wf( + t1_files: list[File], + batch_file: File, + output_dir: Directory, + entities: list[dict]) -> tuple[list[str], tuple[File | list[File]]]: + """ + Compute VBM prep-processing using CAT12. + + Parameters + ---------- + t1_files : list[File] + Path to the T1 files. + batch_file : File + Path to the ready for execution CAT12 batch file + output_dir : Directory + Directory where the prep-processing related outputs will be saved. + entities : list[dict] + Dictionaries of parsed BIDS entities including modality for each input + image file. + + Returns + ------- + command : list[str] + Pre-processing command-line. + outputs : tuple[File | list[File]] + - gm_files : list[File] - Path to the modulated, normalized gray + matter segmentations of the input T1-weighted MRI images. + - qc_files : list[File] - Visual reports. + """ + opts = brainprep_options.get() + 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"]) + + output_dirs = [ + output_dir / f"ses-{info['ses']}" + for info in entities + ] + gm_files = [ + trg_dir / "mri" / 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', '')}" + for im_file, trg_dir in zip(t1_files, output_dirs, strict=True) + ] + + command = [ + str(cat12_file), + "-s", str(spm12_dir), + "-m", str(matlab_dir), + "-b", str(batch_file) + ] + + return command, (gm_files, qc_files) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + bunched=False + ), + PythonWrapperHook(), + ] +) +def write_catbatch( + t1_files: list[File], + output_dir: Directory, + entities: list[dict], + model_long: int = 1, + dryrun: bool = False) -> tuple[File]: + """ + Generate CAT12 batch file. + + Parameters + ---------- + t1_files: list[File] + Path to the T1 files. + output_dir : Directory + Working directory containing the outputs. + entities : list[dict] + Dictionaries of parsed BIDS entities including modality for each input + image file. + model_long : int + Longitudinal model choice:1 short time (weeks), 2 long time (years) + between images sessions. Default 1. + dryrun : bool + If True, skip actual computation and file writing. Default False. + + Returns + ------- + batch_file : File + Path to the ready for execution CAT12 batch file. + + Notes + ----- + - Input T1w image files are unzip in workspace folder. + """ + opts = brainprep_options.get() + tpm_file = opts.get("tpm_file", DEFAULT_OPTIONS["tpm_file"]) + darteltpm_file = opts.get( + "darteltpm_file", DEFAULT_OPTIONS["darteltpm_file"] + ) + + assert len(t1_files) == len(entities) + longitudinal = len(t1_files) != 1 + if longitudinal: + batch_file = ( + output_dir / + "cat12vbm_matlabbatch.m" + ) + template_batch = ( + Path(__file__).parent.parent / + "resources" / + "cat12vbm_matlabbatch_longitudinal.m" + ) + else: + batch_file = ( + output_dir / + 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" / + "cat12vbm_matlabbatch.m" + ) + output_dirs = [ + output_dir / f"ses-{info['ses']}" + for info in entities + ] + unzip_t1_files = [ + trg_dir / im_file.name.replace(".gz", "") + for im_file, trg_dir in zip(t1_files, output_dirs, strict=True) + ] + + for src_file, trg_file in zip(t1_files, unzip_t1_files, strict=True): + ungzfile( + src_file, + trg_file, + trg_file.parent, + ) + + content = template_batch.read_text() + content = content.format( + model_long=model_long, + anat_file=( + " \n".join([ + f"'{path}'" for path in unzip_t1_files + ]) + ), + tpm_file=str(tpm_file), + darteltpm_file=str(darteltpm_file), + ) + batch_file.write_text(content) + + return (batch_file, ) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook( + morphometry=True + ), + LogRuntimeHook( + bunched=False + ), + PythonWrapperHook(), + ] +) +def cat12vbm_morphometry( + output_dir: Directory, + dryrun: bool = False) -> list[File]: + """ + Extract ROI-based morphometry features and global tissue volumes from + CAT12 VBM outputs. + + This function parses CAT12 `.mat` ROI statistics and `.xml` report files + for all subjects in a BIDS-organized dataset. It generates one TSV file + per atlas containing ROI-level gray matter (GM), white matter (WM), and + cerebrospinal fluid (CSF) volumes. It also generates a TSV file + containing total intracranial volume (TIV) and absolute tissue volumes + (GM, WM, CSF). + + 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 GM, WM and CSF features for different + atlases. + A list containing the paths to all generated TSV files. One TSV is + produced per atlas for ROI-based morphometry, plus one TSV + summarizing global tissue volumes. + + Raises + ------ + ValueError + If the XML structure does not contain the expected CAT12 fields, or + if parsing fails for any subject. + """ + atlases = { + "Schaefer2018_100Parcels_17Networks_order": ["Vgm", "Vwm"], + "Schaefer2018_200Parcels_17Networks_order": ["Vgm", "Vwm"], + "Schaefer2018_400Parcels_17Networks_order": ["Vgm", "Vwm"], + "Schaefer2018_600Parcels_17Networks_order": ["Vgm", "Vwm"], + "aal3": ["Vgm"], + "cobra": ["Vgm", "Vwm"], + "hammers": ["Vgm", "Vwm", "Vcsf"], + "ibsr": ["Vgm", "Vwm", "Vcsf"], + "julichbrain": ["Vgm", "Vwm"], + "lpba40": ["Vgm", "Vwm"], + "mori": ["Vgm", "Vwm"], + "neuromorphometrics": ["Vgm", "Vwm", "Vcsf"], + "suit": ["Vgm", "Vwm"], + "thalamic_nuclei": ["Vgm"], + "thalamus": ["Vgm"], + } + morphometry_files = [ + output_dir / f"{atlas}_cat12_vbm_roi.tsv" + for atlas in atlases + ] + + if dryrun: + return (morphometry_files, ) + + mat_files = coerce_to_path( + glob.glob(str( + output_dir.parent / + "subjects" / + "sub-*" / + "ses-*" / + "label" / + "catROI_*T1w.mat" + )), + expected_type=list[File] + ) + entities = [ + parse_bids_keys(path) + for path in mat_files + ] + for atlas, output_file in zip(atlases, morphometry_files, strict=True): + atlas_tissues = atlases[atlas] + data = [] + for info, path in zip(entities, mat_files, strict=True): + data_ = loadmat(path, simplify_cells=True) + ids = data_["S"][atlas]["ids"] + names = data_["S"][atlas]["names"] + features = [] + for tissue in atlas_tissues: + values = data_["S"][atlas]["data"][tissue] + df = pd.DataFrame({ + "ID": [int(val) for val in ids], + "Name": names, + tissue: [float(val) for val in values] + }).T + df.columns = [ + f"{tissue}_{col}" for col in df.loc["Name"] + ] + df = df[2:] + df = df.reset_index(drop=True) + features.append(df) + df = pd.concat(features, axis=1) + 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", + ) + + xml_files = coerce_to_path( + glob.glob(str( + output_dir.parent / + "subjects" / + "sub-*" / + "ses-*" / + "report" / + "cat_*T1w.xml" + )), + expected_type=list[File] + ) + entities = [ + parse_bids_keys(path) + for path in xml_files + ] + df = pd.DataFrame( + columns=[ + "participant_id", "session", "run", + "TIV", "CSF_Vol", "GM_Vol", "WM_Vol", + ] + ) + for info, xml_file in zip(entities, xml_files, strict=True): + cat = pd.read_xml(xml_file) + try: + tiv = float(cat["vol_TIV"][7]) + csf_vol, gm_vol, wm_vol = map( + float, cat["vol_abs_CGW"][7][1:-1].split()[:3] + ) + except Exception as exc: + raise ValueError( + f"Impossible to retrieve TIV: {xml_file}" + ) from exc + df.loc[len(df)] = [ + info["sub"], info["ses"], info["run"], + tiv, csf_vol, gm_vol, wm_vol, + ] + df.sort_values(["participant_id", "session", "run"], inplace=True) + volume_file = output_dir / "cat12_vbm_total_volumes.tsv" + df.to_csv( + volume_file, + sep="\t", + index=False + ) + morphometry_files.append(volume_file) + + return (morphometry_files, ) diff --git a/brainprep/interfaces/fmriprep.py b/brainprep/interfaces/fmriprep.py new file mode 100644 index 00000000..ba0cee07 --- /dev/null +++ b/brainprep/interfaces/fmriprep.py @@ -0,0 +1,423 @@ +########################################################################## +# 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. +########################################################################## + + +""" +fMRIprep functions. +""" + +import glob +import json +import os +import shutil +from pathlib import Path + +import numpy as np +import pandas as pd +from nilearn import ( + datasets, + image, +) +from nilearn.connectome import ConnectivityMeasure +from nilearn.interfaces.fmriprep import load_confounds +from nilearn.maskers import NiftiLabelsMasker + +from ..decorators import ( + CoerceparamsHook, + CommandLineWrapperHook, + LogRuntimeHook, + OutputdirHook, + PythonWrapperHook, + step, +) +from ..typing import ( + Directory, + File, +) +from ..utils import ( + parse_bids_keys, + sidecar_from_file, +) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + bunched=False + ), + CommandLineWrapperHook(), + ] +) +def fmriprep_wf( + t1_file: File, + func_files: list[File], + dataset_description_file: File, + freesurfer_dir: Directory, + workspace_dir: Directory, + output_dir: Directory, + entities: dict) -> tuple[list[str], tuple[File | list[File]]]: + """ + Compute fMRI prep-processing using fMRIPrep. + + Parameters + ---------- + t1_file : File + Path to the input T1w image file. + func_files : list[File] + Path to the input functional image files of one subject. + dataset_description_file : File + Path to the BIDS dataset description file. + freesurfer_dir : Directory + Path to an existing FreeSurfer subjects directory where the reconall + command has already been performed. + workspace_dir: Directory + Working directory with the workspace of the current processing, and + where subject specific data are symlinked. + output_dir : Directory + Directory where the prep-processing related outputs will be saved + entities : dict + A dictionary of parsed BIDS entities including modality. + + Returns + ------- + command : list[str] + Pre-processing command-line. + outputs : tuple[File | list[File]] + - mask_files : File - Brain masks in template spaces. + - fmri_image_files : list[File] - Pre-processed rfMRI volumes: + T1w and MNI152NLin2009cAsym. + - fmri_surf_files: File - Pre-processed rfMRI surfaces: fsnative + and fsLR23k. + - confounds_file : File - File with calculated confounds. + - qc_file : File - Visual report. + + Raises + ------ + ValueError + If the 'FREESURFER_HOME' environment variable is not defined. + + Notes + ----- + - Creates BIDS subject specific working directory using copy in + 'rawdata'. + - Store intermediate pre-processing outputs in 'work'. + """ + 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): + path.mkdir(parents=True, exist_ok=True) + subject, session = entities["sub"], entities["ses"] + fshome_dir = os.getenv("FREESURFER_HOME") + if fshome_dir is None: + raise ValueError( + "You must define the 'FREESURFER_HOME' environment variable." + ) + fshome_dir = Path(fshome_dir) + + for source_file, target_dir in zip( + [t1_file, *func_files], + [anat_dir] + [func_dir] * len(func_files), + strict=True): + sidecar_source_file = sidecar_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 not (rawdata_dir / dataset_description_file.name).is_file(): + shutil.copy( + dataset_description_file, + rawdata_dir / dataset_description_file.name, + ) + + fmriprep_dir = ( + output_dir.parent.parent / f"sub-{subject}" / f"ses-{session}" + ) + qc_file = ( + fmriprep_dir.parent.parent / f"sub-{subject}.html" + ) + rfmri_outputs = [] + for func_file in func_files: + basename = func_file.stem.replace("_bold", "").split(".")[0] + mask_files = [ + ( + fmriprep_dir / + "func" / + f"{basename}_space-{template}_desc-brain_mask.nii.gz" + ) + for template in ("T1w", + "MNI152NLin2009cAsym") + ] + fmri_image_files = [ + ( + fmriprep_dir / + "func" / + f"{basename}_space-{template}_desc-preproc_bold.nii.gz" + ) + for template in ("T1w", + "MNI152NLin2009cAsym") + ] + fmri_surf_files = [ + ( + fmriprep_dir / + "func" / + f"{basename}_space-{template}_den-91k_bold.dtseries.nii", + ) + for template in ("fsnative", + "fsLR") + ] + confounds_file = ( + fmriprep_dir / + "func" / + f"{basename}_desc-confounds_timeseries.tsv" + ) + rfmri_outputs.append([ + mask_files, + fmri_image_files, + fmri_surf_files, + 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, + ] + + return command, (rfmri_outputs, qc_file) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + bunched=False + ), + PythonWrapperHook(), + ] +) +def func_vol_connectivity( + fmri_rest_image_file: File, + mask_file: File, + counfounds_file: File, + workspace_dir: Directory, + output_dir: Directory, + entities: dict, + low_pass: float = 0.1, + high_pass: float = 0.01, + scrub: int = 5, + fd_threshold: float = 0.2, + std_dvars_threshold: int = 3, + detrend: bool = True, + standardize: str | None = "zscore_sample", + remove_volumes: bool = True, + fwhm: float | list[float] = 0., + dryrun: bool = False) -> tuple[File]: + """ + ROI-based functional connectivity from volumetric fMRI data. + + This function uses Nilearn to extract ROI time series and compute + functional connectivity based on the Schaefer 200 ROI atlas. It applies + the Yeo et al. (2011) preprocessing pipeline, including detrending, + filtering, confound regression, and standardization. + + Connectivity is computed using Pearson correlation and saved as a TSV file + following BIDS derivatives conventions. The output filename includes BIDS + entities and specifies the atlas and metric used. + + Preprocessing steps: + + 1. Detrending + 2. Low-pass and high-pass filtering + 3. Confound regression + 4. Standardization + + Filtering: + + - Low-pass removes high-frequency noise (> 0.1 Hz by default). + - High-pass removes scanner drift and low-frequency fluctuations + (< 0.01 Hz by default). + + Confounds: + + - 1 global signal + - 12 motion parameters + derivatives + - 8 discrete cosine basis regressors + - 2 tissue-based confounds (white matter and CSF) + + Total: 23 base confound regressors + + Scrubbing: + + - Volumes with excessive motion (FD > 0.2 mm or standardized DVARS > 3) + are removed. + - Segments shorter than `scrub` frames are discarded. + - One-hot regressors are added for scrubbed volumes. + + Parameters + ---------- + fmri_rest_image_file : File + Pre-processed resting-state fMRI volumes. + mask_file : File + Brain mask used to restrict signal cleaning. + counfounds_file : File + Confounds file from fMRIPrep. + workspace_dir: Directory + Working directory with the workspace of the current processing. + output_dir : Directory + Output directory for generated files. + entities : dict + A dictionary of parsed BIDS entities including modality. + low_pass : float + Low-pass filter cutoff frequency in Hz. Set to None to disable. + Default 0.1. + high_pass : float + High-pass filter cutoff frequency in Hz. Set to None to disable. + Default 0.01 + scrub : int + After accounting for time frames with excessive motion, further remove + segments shorter than the given number. The default value is 5. When + the value is 0, remove time frames based on excessive framewise + displacement and DVARS only. One-hot encoding vectors are added as + regressors for each scrubbed frame. Default 5 + fd_threshold : float + Framewise displacement threshold for scrub. This value is typically + between 0 and 1 mm. Default 0.2 + std_dvars_threshold : int + Standardized DVARS threshold for scrub. DVARs is defined as root mean + squared intensity difference of volume N to volume N + 1. D refers + to temporal derivative of timecourses, VARS referring to root mean + squared variance over voxels. Default 3. + detrend : bool + Detrend data prior to confound removal. Default True + standardize : str | None + Strategy to standardize the signal: 'zscore_sample', 'psc', or None . + Default 'zscore_sample'. + remove_volumes : bool + This flag determines whether contaminated volumes should be removed + from the output data. Default True. + fwhm : float | list[float] + Smoothing strength, expressed as as Full-Width at Half Maximum + (fwhm), in millimeters. Can be a single number ``fwhm=8``, the width + is identical along x, y and z or ``fwhm=0``, no smoothing is performed. + Can be three consecutive numbers, ``fwhm=[1,1.5,2.5]``, giving the fwhm + along each axis. Default 0. + dryrun : bool + If True, skip actual computation and file writing. Default False. + + Returns + ------- + connectivity_file : File + Path to the TSV file containing the ROI-to-ROI connectivity matrix. + + Notes + ----- + - ROI atlas used: Schaefer 2018 (200 regions) + - Connectivity metric: Pearson correlation + """ + basename = "sub-{sub}_ses-{ses}_task-rest_run-{run}_space-{space}".format( + **entities) + if "res" in entities: + basename += f"_res-{entities['res']}" + basename = f"{basename}_atlas-schaefer200_desc-correlation_connectivity" + connectivity_file = ( + output_dir / + f"{basename}.tsv" + ) + + if not dryrun: + atlas = datasets.fetch_atlas_schaefer_2018( + n_rois=200, + data_dir=workspace_dir, + ) + + sidecar_file = ( + fmri_rest_image_file.with_suffix("").with_suffix(".json") + ) + with open(sidecar_file) as of: + info = json.load(of) + tr = info["RepetitionTime"] + + select_confounds, sample_mask = load_confounds( + str(fmri_rest_image_file), + strategy=["high_pass", "motion", "wm_csf", "global_signal"], + motion="derivatives", + wm_csf="basic", + global_signal="basic", + scrub=scrub, + fd_threshold=fd_threshold, + std_dvars_threshold=std_dvars_threshold + ) + if not remove_volumes: + sample_mask = None + + clean_im = image.clean_img( + fmri_rest_image_file, + standardize=standardize, + detrend=detrend, + confounds=select_confounds, + t_r=tr, + high_pass=high_pass, + low_pass=low_pass, + mask_img=mask_file + ) + + if np.array(fwhm).sum() > 0.0: + clean_im = image.smooth_img(clean_im, fwhm) + + masker = NiftiLabelsMasker( + labels_img=atlas.maps, + verbose=5, + ) + timeseries = masker.fit_transform( + clean_im, + sample_mask=sample_mask + ) + + correlation_measure = ConnectivityMeasure( + kind="correlation", + standardize="zscore_sample" + ) + correlation_matrix = correlation_measure.fit_transform( + [timeseries], + )[0] + np.fill_diagonal(correlation_matrix, 0) + correlation_df = pd.DataFrame( + correlation_matrix, + index=atlas.labels[1:], + columns=atlas.labels[1:] + ) + correlation_df.to_csv(connectivity_file, sep="\t") + + return (connectivity_file, ) diff --git a/brainprep/interfaces/freesurfer.py b/brainprep/interfaces/freesurfer.py new file mode 100644 index 00000000..32502b7a --- /dev/null +++ b/brainprep/interfaces/freesurfer.py @@ -0,0 +1,1356 @@ +########################################################################## +# 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. +########################################################################## + + +""" +FreeSurfer functions. +""" + +import glob +import itertools +import os +from pathlib import Path + +import nibabel +import numpy as np +import pandas as pd + +from ..decorators import ( + CoerceparamsHook, + CommandLineWrapperHook, + LogRuntimeHook, + OutputdirHook, + PythonWrapperHook, + step, +) +from ..typing import ( + Directory, + File, +) +from ..utils import ( + parse_bids_keys, + print_info, + print_warn, +) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + bunched=False + ), + CommandLineWrapperHook(), + ] +) +def brainmask( + image_file: File, + output_dir: Directory, + entities: dict) -> tuple[list[str], tuple[File]]: + """ + Skull-strip a BIDS-compliant anatomical image using FreeSurfer's + `mri_synthstrip`. + + `mri_synthstrip` is a FreeSurfer command-line tool that applies + SynthStrip, a deep learning-ased skull-stripping method developed to + work across diverse imaging modalities, resolutions, and subject + population :footcite:p:`hoopes2022brainmask`. + + Parameters + ---------- + image_file : File + Path to the input image file (T1w, T2w, FLAIR, etc.). + 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] + Skull-stripping command-line. + outputs : tuple[File] + - mask_file : File - Skull-stripped brain image file. + + References + ---------- + + .. footbibliography:: + """ + basename = "sub-{sub}_ses-{ses}_run-{run}_mod-{mod}_brainmask".format( + **entities) + mask_file = output_dir / f"{basename}.nii.gz" + + command = [ + "mri_synthstrip", + "-i", str(image_file), + "-m", str(mask_file), + "--no-csf", + ] + + return command, (mask_file, ) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + bunched=False + ), + CommandLineWrapperHook(), + ] +) +def reconall( + t1_file: File, + output_dir: Directory, + entities: dict, + t2_file: File | None = None, + flair_file: File | None = None, + resume: bool = False) -> tuple[list[str], tuple[File]]: + """ + Brain parcellation using FreeSurfer's `recon-all`. + + In FreeSurfer, `recon-all` is the main command used to perform automated + cortical reconstruction and volumetric segmentation from structural MRI + data. It includes multiple processing steps such as skull stripping, + surface reconstruction, and anatomical labeling + :footcite:p:`fischl2012freesurfer`. + + Parameters + ---------- + t1_file : File + Path to the input T1w image file. + output_dir : Directory + FreeSurfer working directory containing all the subjects. + entities : dict + A dictionary of parsed BIDS entities including modality. + t2_file : File | None + Path to the input T2w image file - used to improve the pial surface. + Default None. + flair_file : File | None + Path to the input FLAIR image file - used to improve the pial surface. + 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. + + Returns + ------- + command : list[str] + Brain parcellation command-line. + outputs : tuple[File] + - log_file : File - Generated log file. + + References + ---------- + + .. footbibliography:: + """ + subject = f"run-{entities['run']}" + log_file = output_dir / subject / "scripts" / "recon-all.log" + + command = [ + "recon-all", "-all", + "-subjid", subject, + "-i", str(t1_file), + "-sd", str(output_dir), + "-noappend", + "-no-isrunning" + ] + if t2_file is not None: + command.extend([ + "-T2", str(t2_file), + "-T2pial" + ]) + if flair_file is not None: + command.extend([ + "-FLAIR", str(flair_file), + "-FLAIRpial" + ]) + if resume: + command[1] = "-make all" + + return command, (log_file, ) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + bunched=False + ), + CommandLineWrapperHook(), + ] +) +def reconall_longitudinal( + workspace_dir: Directory, + output_dir: Directory, + entities: dict) -> tuple[list[str], tuple[File | list[File]]]: + """ + Longitudinal brain parcellation using FreeSurfer's `recon-all`. + + Assuming you have run recon-all for all timepoints of a given subject, + 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 + template. + + Parameters + ---------- + workspace_dir: Directory + Working directory where FreeSurfer outputs are reorganized to + run longitudinal commands. + output_dir : Directory + FreeSurfer working directory containing all the subject + entities : dict + A dictionary of parsed BIDS entities including modality. + + Returns + ------- + command : list[str] + Brain parcellation command-line. + outputs : tuple[File | list[File]] + - log_template_file : File - Generated log file for the template + creation step. + - log_files : list[File] - Generated log files for the parcellation + refinements. + + Raises + ------ + ValueError + If a cross-sectional `recon-all` is not available or if multiple + subjects are passed as inputs. + """ + subjects, sessions, runs = zip(*[ + (info["sub"], info["ses"], info["run"]) + for info in entities + ], strict=True) + unique_subjects = set(subjects) + if len(unique_subjects) != 1: + raise ValueError( + f"Expect longitudinal data from one subject: {unique_subjects}" + ) + subject = subjects[0] + + workspace_subjects = [] + for sub, ses, run in zip(subjects, sessions, runs, strict=True): + source_dir = ( + output_dir.parent.parent.parent / + "subjects" / + f"sub-{sub}" / + f"ses-{ses}" / + f"run-{run}" + ) + if not source_dir.is_dir(): + raise ValueError( + f"First run a cross sectional recon-all: {source_dir}" + ) + target_dir = ( + output_dir / + f"sub-{sub}_ses-{ses}_run-{run}" + ) + target_dir.parent.mkdir(parents=True, exist_ok=True) + if not target_dir.is_symlink(): + os.symlink(source_dir, target_dir) + workspace_subjects.append(f"sub-{sub}_ses-{ses}_run-{run}") + + template_name = f"sub-{subject}_template_ses-{':'.join(sessions)}" + log_template_file = ( + output_dir / template_name / "scripts" / "recon-all.log" + ) + log_files = [ + ( + output_dir / + f"{sub_name}.long.{template_name}" / + "scripts" / + "recon-all.log" + ) for sub_name in workspace_subjects + ] + commands = [ + [ + "recon-all", + "-base", + template_name, + *itertools.chain.from_iterable([ + ["-tp", sub] for sub in workspace_subjects + ]), + "-all", + "-sd", str(output_dir), + ], + *[ + [ + "recon-all", + "-long", + sub, + template_name, + "-all", + "-sd", str(output_dir), + ] for sub in workspace_subjects + ], + ] + + return commands, (log_template_file, log_files) + + +@step( + hooks=[ + CoerceparamsHook(), + LogRuntimeHook( + bunched=False + ), + PythonWrapperHook(), + ] +) +def freesurfer_command_status( + log_file: File, + command: str, + dryrun: bool = False) -> None: + """ + Check the status of a FreeSurfer `recon-all` process from its log file. + + Parameters + ---------- + log_file : File + Path to the recon-all.log file. + command : str + 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. + + Raises + ------ + FileNotFoundError + If no input log file. + ValueError + If the command-line is not supported. + RuntimeError + If recon-all failed or its status is unclear. + + Notes + ----- + - Success is determined by the presence of the phrase + "finished without error" in the last lines of the log. + - Errors are detected by scanning for lines containing "ERROR:" or + "FATAL:". + - This function raises exceptions to signal failure or ambiguity, and + does not return any value. + """ + if not dryrun: + + 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:"] + + 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) + ) + raise RuntimeError( + "Recon-all status unclear. No success or error markers found." + ) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + bunched=False + ), + CommandLineWrapperHook(), + ] +) +def localgi( + output_dir: Directory, + entities: dict) -> tuple[list[str], tuple[File]]: + """ + Local Gyrification Index (localGI or lGI). + + It quantifies how much cortex is buried within the sulcal folds compared + to the exposed outer surface - providing a localized version of the + classical gyrification index :footcite:p:`schaer2008lgi`. + + Parameters + ---------- + output_dir : Directory + FreeSurfer working directory containing all the subjects. + entities : dict + A dictionary of parsed BIDS entities including modality. + + Returns + ------- + command : list[str] + LocalGI command-line. + outputs : tuple[File] + - left_lgi_file : File - The generated left hemisphere localGI file. + - right_lgi_file : File - The generated right hemisphere localGI file. + + References + ---------- + + .. footbibliography:: + """ + subject = f"run-{entities['run']}" + left_lgi_file = output_dir / subject / "surf" / "lh.pial_lgi" + right_lgi_file = output_dir / subject / "surf" / "rh.pial_lgi" + + command = [ + "recon-all", "-localGI", + "-subjid", subject, + "-sd", str(output_dir), + "-no-isrunning" + ] + + 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]]: + """ + Surface-based registration to `fsaverage_sym` symmetric template. + + 1) Registers the left hemisphere to the `fsaverage_sym` symmetric template. + 2) Registers the right hemisphere (already aligned to the left via + xhemireg) to `fsaverage_sym` - use the interhemispheric registration + surface (rh.sphere.reg.xhemi) instead of the standard one + (rh.sphere.reg). + + Parameters + ---------- + output_dir : Directory + FreeSurfer working directory containing all the subjects. + entities : dict + A dictionary of parsed BIDS entities including modality. + + Returns + ------- + command : list[str] + Registration command-line. + 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. + """ + subject = f"run-{entities['run']}" + left_reg_file = ( + output_dir / subject / "surf" / "lh.fsaverage_sym.sphere.reg" + ) + right_reg_file = ( + output_dir / subject / "xhemi" / "surf" / "lh.fsaverage_sym.sphere.reg" + ) + + left_reg_file = ( + output_dir / subject / "surf" / "lh.fsaverage_sym.sphere.reg" + ) + for reg_file_ in (right_reg_file, left_reg_file): + if reg_file_.is_file(): + print_info(f"removing: {reg_file_}") + os.remove(reg_file_) + + commands = [ + [ + "surfreg", + "--s", subject, + "--lh", + "--t", "fsaverage_sym", + "--no-annot", + ], + [ + "surfreg", + "--s", subject, + "--lh", + "--t", "fsaverage_sym", + "--no-annot", + "--xhemi", + ], + ] + + 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]]: + """ + Symmetric mapping of the right hemisphere to the left hemisphere space. + within the subject's own space. + + Parameters + ---------- + output_dir : Directory + FreeSurfer working directory containing all the subjects. + entities : dict + A dictionary of parsed BIDS entities including modality. + + Returns + ------- + command : list[str] + Mapping command-line. + outputs : tuple[File] + - left_log_file : File - The log of the left to right registration + process. + - right_log_file : File - The log of the right to left registration + process. + """ + subject = f"run-{entities['run']}" + left_log_file = output_dir / subject / "xhemi" / "xhemireg.lh.log" + right_log_file = output_dir / subject / "xhemi" / "xhemireg.rh.log" + + command = [ + "xhemireg", + "--s", subject, + ] + + return command, (left_log_file, right_log_file) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + bunched=False + ), + ] +) +def fsaveragesym_surfreg( + output_dir: Directory, + entities: dict) -> tuple[File, File]: + """ + Interhemispheric surface-based registration using the `fsaverage_sym` + template, and FreeSurfer's `xhemireg` and `surfreg`. + + Applies the interhemispheric cortical surface-based pre-processing + described in :footcite:p:`greve2013xhemi`. This includes: + + 1) Registers the right hemisphere to the left hemisphere within the + subject's own space. + 2) Registers the left hemisphere to the `fsaverage_sym` symmetric template. + 3) Registers the right hemisphere (already aligned to the left via + xhemireg) to `fsaverage_sym` - use the interhemispheric registration + surface (rh.sphere.reg.xhemi) instead of the standard one + (rh.sphere.reg). + + Parameters + ---------- + output_dir : Directory + FreeSurfer working directory containing all the subjects. + entities : dict + A dictionary of parsed BIDS entities including modality. + + 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. + + Notes + ----- + - Removing the `left_reg_file` if trying to resume. + + References + ---------- + + .. footbibliography:: + """ + template_dir = output_dir / "fsaverage_sym" + if template_dir.is_symlink(): + os.remove(template_dir) + + subject = f"run-{entities['run']}" + os.environ["SUBJECTS_DIR"] = str(output_dir) + + _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( + output_dir, + entities, + ) + + return (left_reg_file, right_reg_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]]: + """ + Apply a surface-based registration to a cortical surface data file. + + It transforms data from one surface space to another using a precomputed + registration. + + Parameters + ---------- + src_file : File + Source cortical features. + trg_file : File + Target cortical features. + srcreg_file : File + Source reg file. + targreg_file : File + Target reg file. + + Returns + ------- + command : list[str] + Apply registration command-line. + outputs : tuple[File] + - trg_file : File - Target cortical features. + """ + command = [ + "mris_apply_reg", + "--src", str(src_file), + "--trg", str(trg_file), + "--streg", str(srcreg_file), str(targreg_file), + ] + + return command, (trg_file, ) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + bunched=False + ), + PythonWrapperHook(), + ] +) +def fsaveragesym_projection( + left_reg_file: File, + right_reg_file: File, + output_dir: Directory, + entities: dict, + dryrun: bool = False) -> tuple[File]: + """ + Project the different cortical features to the 'fsaverage_sym' template + space using FreeSurfer's `mris_apply_reg`. + + Map the left and right hemisphere cortical features to 'fsaverage_sym' + left hemisphere. This includes the following features: + - thickness + - curvature + - area + - localGI + - sulc + + Parameters + ---------- + 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. + output_dir : Directory + 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] + 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: + - 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 + + Notes + ----- + - This function is resilient if a feature is missing. In this case a + None is returned. + """ + subject = f"run-{entities['run']}" + template_dir = output_dir / "fsaverage_sym" + reg_map = { + "lh": left_reg_file, + "rh": right_reg_file, + "template": template_dir / "surf" / "lh.sphere.reg" + } + + features = [] + for name in ("thickness", "curv", "area", "pial_lgi", "sulc"): + for hemi in ("lh", "rh"): + src_feature_file = ( + output_dir / subject / "surf" / f"{hemi}.{name}" + ) + if not src_feature_file.is_file(): + print_warn(f"feature missing: {src_feature_file}") + if not dryrun: + features.append(None) + continue + trg_feature_file = ( + output_dir / subject / "surf" / + f"{hemi}.{name}.fsaverage_sym.mgh" + ) + 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"], + ) + features.append(trg_feature_file) + + return 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]]: + """ + Convert a source image and resample it to match the resolution, + orientation, and voxel grid of a reference image using FreeSurfer's + `mri_convert`. + + Parameters + ---------- + src_file : File + Source image. + trg_file : File + Target image. + reference_file : File + Reference image. + + Returns + ------- + command : list[str] + Conversion command-line. + outputs : tuple[File] + - trg_file : File - Target image. + """ + command = [ + "mri_convert", + "--resample_type", "nearest", + "--reslice_like", str(reference_file), + str(src_file), + str(trg_file), + ] + + return command, (trg_file, ) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + bunched=False + ), + ] +) +def mgz_to_nii( + output_dir: Directory, + entities: dict) -> tuple[File]: + """ + Convert FreeSurfer images back to original Nifti space. + + Convert FreeSurfer MGZ file in Nifti format and reslice the generated image + as the 'mri/rawavg.mgz' image. A nearest neighbor interpolation is used. + This includes the following images: + - aparc+aseg + - aparc.a2009s+aseg + - aseg + - wm + - rawavg + - ribbon + - brain + + Parameters + ---------- + output_dir : Directory + FreeSurfer working directory containing all the subjects. + entities : dict + A dictionary of parsed BIDS entities including modality. + + Returns + ------- + images: tuple[File] + A tuple containing converted images. The images are returned in the + following order: + - aparc_aseg_file + - aparc_a2009s_aseg_file + - aseg_file + - wm_file + - rawavg_file + - ribbon_file + - brain_file + """ + subject = f"run-{entities['run']}" + reference_file = output_dir / subject / "mri" / "rawavg.mgz" + + 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, + ) + images.append(trg_file) + + return 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]]: + """ + Summarizes the stats data '?h.aparc.stats' for both templates (Desikan & + Destrieux) using FreeSurfer's `aparcstats2table`. + + Parameters + ---------- + subjects : list[str] + List with subject identifiers. + session : str + The current session. + hemi : str + The hemisphere: 'lh' or 'rh'. + measure : str + The cortical measure. + output_dir : Directory + FreeSurfer working directory containing all the subjects. + + Returns + ------- + command : list[str] + Summary command-line. + outputs : tuple[File] + - desikan_stat_file : File - Desikan template cortical features + summary. + - destrieux_stat_file : File - Destrieux template cortical features + summary. + """ + desikan_stat_file = ( + output_dir / + f"aparc_ses-{session}_hemi-{hemi}_meas-{measure}_stats.csv" + ) + destrieux_stat_file = ( + output_dir / + f"aparc2009s_ses-{session}_hemi-{hemi}_meas-{measure}_stats.csv" + ) + commands = [ + [ + "aparcstats2table", + "--hemi", hemi, + "--meas", measure, + "--tablefile", str(desikan_stat_file), + "--delimiter", "comma", + "--parcid-only", + "--subjects", + *subjects, + ], + [ + "aparcstats2table", + "--parc", "aparc.a2009s", + "--hemi", hemi, + "--meas", measure, + "--tablefile", str(destrieux_stat_file), + "--delimiter", "comma", + "--parcid-only", + "--subjects", + *subjects, + ], + ] + + 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]]: + """ + Summarizes the volumetric data for subcortical brain structures + 'aseg.stats' using FreeSurfer's `asegstats2table`. + + Parameters + ---------- + subjects : list[str] + List with subject identifiers. + session : str + The current session. + output_dir : Directory + FreeSurfer working directory containing all the subjects. + + Returns + ------- + command : list[str] + Summary command-line. + outputs : tuple[File] + - volume_stat_file : File - Volumetric subcortical brain structure + features summary. + """ + volume_stat_file = output_dir / f"aseg_ses-{session}_stats.csv" + + command = [ + "asegstats2table", + "--meas", "volume", + "--tablefile", str(volume_stat_file), + "--delimiter", "comma", + "--subjects", + *subjects, + ] + + return command, (volume_stat_file, ) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook( + morphometry=True + ), + LogRuntimeHook( + bunched=False + ), + ] +) +def freesurfer_features_summary( + workspace_dir: Directory, + output_dir: Directory) -> tuple[File]: + """ + Summarizes the generated FreeSurfer features for all subjects. + + Generate text/ascii tables of FreeSurfer parcellation stats data + '?h.aparc.stats' for both templates (Desikan & Destrieux) and + volumetric data for subcortical brain structures 'aseg.stats'. + Parcellation stats includes: + - area + - volume + - thickness + - thicknessstd + - meancurv + - gauscurv + - foldind + - curvind + + Parameters + ---------- + workspace_dir: Directory + Working directory where FreeSurfer outputs are reorganized to + run new commands. + output_dir : Directory + FreeSurfer working directory containing all the subjects. + + Returns + ------- + statfiles: 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 + - destrieux_stat_lh__file + - desikan_stat_rh__file + - destrieux_stat_rh__file + - volume_stat_file + + Notes + ----- + - Creates FreeSurfer 'SUBJECTS_DIR' for each timepoint in a working + directory using symlinks. + """ + stats_dirs = glob.glob(str( + output_dir.parent / + "subjects" / + "sub-*" / + "ses-*" / + "run-*" / + "stats" / + "lh.aparc.stats" + )) + source_dirs = [ + str(Path(item).parent.parent) for item in stats_dirs + ] + subjects, sessions, runs = zip(*[ + item.lstrip(os.sep).split(os.sep)[-3:] + for item in source_dirs + ], strict=True) + unique_sessions = set(sessions) + + fs_subjects = {} + for sub, ses, run, source_dir in zip( + subjects, sessions, runs, source_dirs, strict=True): + target_dir = workspace_dir / ses / f"{sub}_{run}" + target_dir.parent.mkdir(parents=True, exist_ok=True) + if not target_dir.is_symlink(): + os.symlink(source_dir, target_dir) + fs_subjects.setdefault(ses, []).append(f"{sub}_{run}") + + summary_files = [] + measures = [ + "area", "volume", "thickness", "thicknessstd", + "meancurv", "gauscurv", "foldind", "curvind" + ] + for ses in unique_sessions: + data_dir = workspace_dir / ses + os.environ["SUBJECTS_DIR"] = str(data_dir) + + for hemi in ["lh", "rh"]: + for meas in measures: + desikan_stat_file, destrieux_stat_file = aparcstats2table( + fs_subjects[ses], + ses.replace("ses-", ""), + hemi, + meas, + output_dir, + ) + summary_files.extend([ + desikan_stat_file, + destrieux_stat_file, + ]) + + 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: + continue + df = pd.read_csv(file, sep=sep) + first_col = df.columns[0] + df = df.sort_values(by=first_col) + df.to_csv(file, sep=sep, index=False) + + return summary_files + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + bunched=False + ), + PythonWrapperHook(), + ] +) +def freesurfer_tissues( + workspace_dir: Directory, + output_dir: Directory, + entities: dict, + include_cerebellum: bool = False, + 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 + segmentations. + + Ribbon-based structures: + + - WM - Left/Right Cerebral White Matter. + - GM - Left/Right Cerebral Cortex. + + wmparc-based structures: + + - CC - Fornix, CC-Posterior, CC-Mid-Posterior, CC-Central, + CC-Mid-Anterior, CC-Anterior. + - CSF - Left-Lateral-Ventricle, Left-Inf-Lat-Vent, + 3rd-Ventricle, 4th-Ventricle, CSF Left-Choroid-Plexus, + Right-Lateral-Ventricle, Right-Inf-Lat-Vent, Right-Choroid-Plexus. + - WM - Cerebellar-White-Matter-Left, Brain-Stem, + Cerebellar-White-Matter-Right. + - GM - Left-Cerebellar-Cortex, Right-Cerebellar-Cortex, Thalamus-Left, + Caudate-Left, Putamen-Left, Pallidum-Left, Hippocampus-Left, + Amygdala-Left, Accumbens-Left, Diencephalon-Ventral-Left, + Thalamus-Right, Caudate-Right, Putamen-Right, Pallidum-Right, + Hippocampus-Right, Amygdala-Right, Accumbens-Right, + Diencephalon-Ventral-Right. + + Parameters + ---------- + workspace_dir: Directory + Working directory where intermediate FreeSurfer outputs are generated. + output_dir : Directory + FreeSurfer working directory containing all the subjects. + entities : dict + A dictionary of parsed BIDS entities including modality. + include_cerebellum : bool + If False, omit cerebellum and brain stem. Default False. + dryrun : bool + If True, skip actual computation and file writing. Default False. + + Returns + ------- + wm_mask_file : File + Binary mask of white matter regions. + gm_mask_file : File + Binary mask of gray matter regions. + csf_mask_file : File + Binary mask of cerebrospinal fluid regions. + brain_mask_file : File + Binary mask of the brain. + + Notes + ----- + This function uses predefined FreeSurfer label indices to classify tissue + types. It combines ribbon and wmparc segmentation maps to generate + comprehensive masks for downstream analysis or visualization. Use + FreeSurfer's `ribbon.mgz` and `wmparc.mgz` files. + """ + subject = f"run-{entities['run']}" + wm_mask_file = workspace_dir / f"wm_{subject}.nii.gz" + gm_mask_file = workspace_dir / f"gm_{subject}.nii.gz" + csf_mask_file = workspace_dir / f"csf_{subject}.nii.gz" + brain_mask_file = workspace_dir / f"brain_{subject}.nii.gz" + + if not dryrun: + + ribbon_file = output_dir / subject / "mri" / "ribbon.mgz" + wmparc_file = output_dir / subject / "mri" / "wmparc.mgz" + + 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 + ] + 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() + + wm_mask_arr = np.logical_and( + np.logical_and( + 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) + ) + + 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 + ) + + 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) + + +@step( + hooks=[ + CoerceparamsHook(), + LogRuntimeHook( + bunched=False + ), + CommandLineWrapperHook(), + ] +) +def nextbrain( + t1_file: File, + output_dir: Directory, + entities: dict) -> tuple[list[str], tuple[File]]: + """ + Uses NextBrain probabilistic atlas of the human brain, to segment ~300 + distinct ROIs per hemisphere. + + Segmentation relies on a Bayesian algorithm and is thus robust against + changes in MRI pulse sequence (e.g., T1-weighted, T2-weighted, + FLAIR, etc). + + Parameters + ---------- + t1_file : File + Path to the input T1w image file. + output_dir : Directory + FreeSurfer working directory containing all the subjects. + entities : dict + A dictionary of parsed BIDS entities including modality. + + Returns + ------- + command : list[str] + NextBrain parcellation command-lines. + outputs : tuple[File] + - left_seg_file : File - Generated left hemisphere NextBrain atlas. + - right_seg_file : File - Generated right hemisphere NextBrain atlas. + + References + ---------- + + .. footbibliography:: + """ + subject = f"run-{entities['run']}" + left_seg_file = output_dir / subject / "nextbrain" / "seg.left.nii.gz" + right_seg_file = output_dir / subject / "nextbrain" / "seg.right.nii.gz" + + command = [ + "mri_histo_atlas_segment_fireants", + str(t1_file), + str(output_dir / subject / "nextbrain"), + "0", + str(os.cpu_count()) + ] + + return command, (left_seg_file, right_seg_file) diff --git a/brainprep/interfaces/fsl.py b/brainprep/interfaces/fsl.py new file mode 100644 index 00000000..09c66e93 --- /dev/null +++ b/brainprep/interfaces/fsl.py @@ -0,0 +1,365 @@ +########################################################################## +# 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. +########################################################################## + + +""" +FSL functions. +""" + +from pathlib import Path + +from ..decorators import ( + CoerceparamsHook, + CommandLineWrapperHook, + LogRuntimeHook, + OutputdirHook, + step, +) +from ..typing import ( + Directory, + File, +) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + bunched=False + ), + CommandLineWrapperHook(), + ] +) +def reorient( + image_file: File, + output_dir: Directory, + entities: dict) -> tuple[list[str], tuple[File]]: + """ + Reorients a BIDS-compliant anatomical image using FSL's `fslreorient2std`. + + Parameters + ---------- + image_file : File + Path to the input image file. + 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] + Reorientation command-line. + outputs : tuple[File] + - reorient_image_file : File - Reoriented input image file. + """ + basename = "sub-{sub}_ses-{ses}_run-{run}_mod-T1w_reorient".format( + **entities) + reorient_image_file = output_dir / f"{basename}.nii.gz" + + command = [ + "fslreorient2std", + str(image_file), + str(reorient_image_file) + ] + + return command, (reorient_image_file, ) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + bunched=False + ), + CommandLineWrapperHook(), + ] +) +def deface( + t1_file: File, + output_dir: Directory, + entities: dict) -> tuple[list[str], tuple[File | list[File]]]: + """ + Defaces a BIDS-compliant T1-weighted anatomical image using FSL's + `fsl_deface`. + + Parameters + ---------- + t1_file : File + Path to the input T1w image file. + output_dir : Directory + Directory where the defaced T1w image will be saved. + entities : dict + A dictionary of parsed BIDS entities including modality. + + Returns + ------- + command : list[str] + Defacing command-line. + 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. + + Raises + ------ + ValueError + If the input image is not a T1-weighted image. + """ + modality = entities.get("mod") + if modality is None or modality != "T1w": + raise ValueError( + f"The '{t1_file}' input anatomical file must be a T1w image." + ) + + basename = "sub-{sub}_ses-{ses}_run-{run}_mod-T1w_deface".format( + **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", + ] + + return command, (deface_file, mask_file, ) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + bunched=False + ), + CommandLineWrapperHook(), + ] +) +def applymask( + image_file: File, + mask_file: File, + output_dir: Directory, + entities: dict) -> tuple[list[str], tuple[File]]: + """ + Apply an isotropic resampling transformation to a BIDS-compliant image + file using FSL's `fslmaths`. + + Parameters + ---------- + image_file : File + Path to the input image file. + mask_file : File + Path to a binary mask file. + output_dir : Directory + Directory where the masked image will be saved. + entities : dict + A dictionary of parsed BIDS entities including modality. + + Returns + ------- + command : list[str] + Masking command-line. + outputs : tuple[File] + - masked_image_file : File - masked input image file. + """ + basename = "sub-{sub}_ses-{ses}_run-{run}_mod-{mod}_applymask".format( + **entities) + masked_image_file = output_dir / f"{basename}.nii.gz" + + command = [ + "fslmaths", + str(image_file), + "-mas", str(mask_file), + str(masked_image_file), + ] + + return command, (masked_image_file, ) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + bunched=False + ), + CommandLineWrapperHook(), + ] +) +def scale( + image_file: File, + scale: int, + output_dir: Directory, + entities: dict) -> tuple[list[str], tuple[File]]: + """ + Apply an isotropic resampling transformation to a BIDS-compliant image + file using FSL's `flirt`. + + Parameters + ---------- + image_file : File + Path to the input image file. + scale : int + Scale factor applied in all directions. + output_dir : Directory + Directory where the scaled image will be saved. + entities : dict + A dictionary of parsed BIDS entities including modality. + + Returns + ------- + command : list[str] + Scaling command-line. + outputs : tuple[File] + - scaled_anatomical_file : File - Scaled input image file. + - transform_file : File - The associated transformation file. + """ + basename = "sub-{sub}_ses-{ses}_run-{run}_mod-{mod}_scale".format( + **entities) + scaled_anatomical_file = output_dir / f"{basename}.nii.gz" + transform_file = output_dir / f"{basename}.txt" + + command = [ + "flirt", + "-in", str(image_file), + "-ref", str(image_file), + "-applyisoxfm", str(scale), + "-out", str(scaled_anatomical_file), + "-omat", str(transform_file), + "-verbose", "1", + ] + + return command, (scaled_anatomical_file, transform_file) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + bunched=False + ), + CommandLineWrapperHook(), + ] +) +def affine( + anatomical_file: File, + template_file: File, + output_dir: Directory, + entities: dict) -> tuple[list[str], tuple[File]]: + """ + Affinely register a BIDS-compliant anatomical image to a template file + using FSL's `flirt`. + + Parameters + ---------- + anatomical_file : File + Path to the input image file. + template_file: File + Path to the image file defining the template space. + output_dir : Directory + Directory where the affine transformation will be saved. + entities : dict + A dictionary of parsed BIDS entities including modality. + + Returns + ------- + command : list[str] + Registration command-line. + outputs : tuple[File] + - aligned_anatomical_file : File - Aligned input image file. + - transform_file : File - The affine transformation file. + """ + basename = "sub-{sub}_ses-{ses}_run-{run}_mod-{mod}_affine".format( + **entities) + aligned_anatomical_file = output_dir / f"{basename}.nii.gz" + transform_file = output_dir / f"{basename}.txt" + + command = [ + "flirt", + "-in", str(anatomical_file), + "-ref", str(template_file), + "-cost", "normmi", + "-searchcost", "normmi", + "-anglerep", "euler", + "-bins", "256", + "-interp", "trilinear", + "-dof", "9", + "-out", str(aligned_anatomical_file), + "-omat", str(transform_file), + "-verbose", "1" + ] + + return command, (aligned_anatomical_file, transform_file) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + bunched=False + ), + CommandLineWrapperHook(), + ] +) +def applyaffine( + image_file: File, + template_file: File, + transform_file: File, + output_dir: Directory, + entities: dict, + interpolation: str = "spline") -> tuple[list[str], tuple[File]]: + """ + Apply an affine transformation to a BIDS-compliant image file using FSL's + `flirt`. + + Parameters + ---------- + image_file : File + Path to the input image file. + template_file: File + Path to the image file defining the template space. + transform_file : File + Path to the affine transformation file. + output_dir : Directory + Directory where the aligned 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 + ------- + command : list[str] + Alignment command-line. + outputs : tuple[File] + - aligned_image_file : File - Aligned input image file. + """ + basename = "sub-{sub}_ses-{ses}_run-{run}_mod-{mod}_applyaffine".format( + **entities) + aligned_image_file = output_dir / f"{basename}.nii.gz" + + command = [ + "flirt", + "-in", str(image_file), + "-ref", str(template_file), + "-init", str(transform_file), + "-interp", str(interpolation), + "-applyxfm", + "-out", str(aligned_image_file), + ] + + return command, (aligned_image_file, ) diff --git a/brainprep/interfaces/mriqc.py b/brainprep/interfaces/mriqc.py new file mode 100644 index 00000000..3d4322f5 --- /dev/null +++ b/brainprep/interfaces/mriqc.py @@ -0,0 +1,142 @@ +########################################################################## +# 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. +########################################################################## + + +""" +MRIQC functions. +""" + +from pathlib import Path + +from ..decorators import ( + CoerceparamsHook, + CommandLineWrapperHook, + LogRuntimeHook, + OutputdirHook, + step, +) +from ..typing import ( + Directory, + File, +) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + bunched=False + ), + CommandLineWrapperHook(), + ] +) +def subject_level_qa( + image_files: list[File], + workspace_dir: Directory, + output_dir: Directory) -> tuple[list[str], tuple[File | list[File]]]: + """ + Compute subject level Image Quality Metrics (IQMs) generated by MRIQC. + + Parameters + ---------- + image_files : list[File] + Path to the input image files. + workspace_dir : Directory + The workspace of the current processing. + output_dir : Directory + Directory where the IQMs will be saved. + + Returns + ------- + command : list[str] + Cross sectional quality analysis command-line. + outputs : tuple[File | list[File]] + - iqm_files : list[File] - IQM files. + """ + rawdata_dir = image_files[0].parent.parent.parent.parent + subject = output_dir.parent.name.replace("sub-", "") + output_dir = output_dir.parent.parent + + iqm_files = [ + path.parent / f"{path.name.split('.')[0]}.json" + for path in image_files + ] + iqm_files = [ + Path( + str(path).replace( + str(rawdata_dir), + str(output_dir) + ) + ) + for path in iqm_files + ] + + command = [ + "mriqc", + str(rawdata_dir), + str(output_dir), + "participant", + "-w", str(workspace_dir), + "--no-sub", + "--no-datalad-get", + "--notrack", + "--participant-label", subject, + ] + + return command, (iqm_files, ) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + bunched=False + ), + CommandLineWrapperHook(), + ] +) +def group_level_qa( + modalities: list[str], + output_dir: Directory) -> tuple[list[str], tuple[File | list[File]]]: + """ + Compute group level Image Quality Metrics (IQMs) generated by MRIQC. + + Parameters + ---------- + modalities: list[str] + The modalities in the current study. + output_dir : Directory + Directory where the IQMs will be saved. + + Returns + ------- + command : list[str] + Group quality analysis command-line. + outputs : tuple[File | list[File]] + - iqm_files : list[File] - Group IQM files. + """ + rawdata_dir = output_dir.parent.parent / "rawdata" + + iqm_files = [ + output_dir / f"group_{mod}.tsv" + for mod in modalities + ] + + command = [ + "mriqc", + str(rawdata_dir), + str(output_dir), + "group", + "--no-sub", + "--no-datalad-get", + "--notrack", + ] + + return command, (iqm_files, ) diff --git a/brainprep/interfaces/plotting.py b/brainprep/interfaces/plotting.py new file mode 100644 index 00000000..e6509bc7 --- /dev/null +++ b/brainprep/interfaces/plotting.py @@ -0,0 +1,399 @@ +########################################################################## +# 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. +########################################################################## + +""" +Plotting functions. +""" + +import itertools +import warnings + +import matplotlib.pyplot as plt +import nibabel +import numpy as np +import pandas as pd +import seaborn as sns + +with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message=".*'agg' matplotlib backend.*", + category=UserWarning + ) + from nilearn import plotting + +from ..decorators import ( + CoerceparamsHook, + LogRuntimeHook, + OutputdirHook, + PythonWrapperHook, + step, +) +from ..typing import ( + Directory, + File, +) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook( + plotting=True + ), + LogRuntimeHook( + bunched=False + ), + PythonWrapperHook(), + ] +) +def plot_network( + network_file: File, + output_dir: Directory, + entities: dict, + dryrun: bool = False) -> tuple[File]: + """ + Plot the given network using nilearn. + + The generated image will have the same name as the input network file. + + Parameters + ---------- + network_file : File + Path to a TSV file containing a square connectivity matrix. + The first column and the first row are interpreted as labels and are + used to annotate the plotted matrix. + output_dir : Directory + Directory where the 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 + ------- + network_image_file : File + Path to the saved network image. + """ + basename = network_file.stem + network_image_file = output_dir / f"{basename}.png" + + if dryrun: + return (network_image_file, ) + + df = pd.read_csv(network_file, sep="\t", index_col=0) + labels = df.index.tolist() + + display = plotting.plot_matrix( + df.values, + figure=(10, 8), + labels=labels, + reorder=True, + ) + display.figure.savefig(network_image_file) + + return (network_image_file, ) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook( + plotting=True + ), + LogRuntimeHook( + bunched=False + ), + PythonWrapperHook(), + ] +) +def plot_defacing_mosaic( + im_file: File, + mask_file: File, + output_dir: Directory, + entities: dict, + dryrun: bool = False) -> tuple[File]: + """ + Generates a defacing mosaic image by overlaying a mask on an anatomical + image. + + Parameters + ---------- + im_file : File + Path to the anatomical image. + mask_file : File + Path to the defacing mask. + output_dir : Directory + Directory where the mosaic 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 + ------- + mosaic_file : File + Path to the saved mosaic image. + """ + basename = "sub-{sub}_ses-{ses}_run-{run}_mod-T1w_deface".format( + **entities) + mosaic_file = output_dir / f"{basename}mosaic.png" + + if dryrun: + return (mosaic_file, ) + + plotting.plot_roi( + mask_file, + bg_img=im_file, + display_mode="z", + cut_coords=25, + black_bg=True, + alpha=0.6, + colorbar=False, + output_file=mosaic_file + ) + arr = plt.imread(mosaic_file) + cut = int(arr.shape[1] / 5) + plt.figure() + arr = np.concatenate( + [arr[:, idx * cut: (idx + 1) * cut] for idx in range(5)], axis=0) + plt.imshow(arr) + plt.axis("off") + plt.savefig(mosaic_file) + + return (mosaic_file, ) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook( + plotting=True + ), + LogRuntimeHook( + bunched=False + ), + PythonWrapperHook(), + ] +) +def plot_histogram( + table_file: File, + col_name: str, + output_dir: Directory, + bar_coords: list[float] | None = None, + dryrun: bool = False) -> tuple[File]: + """ + Generates a histogram image with optional vertical bars. + + Parameters + ---------- + table_file : File + TSV table containing the data to be displayed. + col_name : str + Name of the column containing the histogram data. + 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. + dryrun : bool + If True, skip actual computation and file writing. Default False. + + Returns + ------- + histogram_file : File + Generated image with the histogram. + """ + histogram_file = output_dir / f"histogram_{col_name}.png" + + if dryrun: + return (histogram_file, ) + + data = pd.read_csv( + table_file, + sep="\t", + ) + arr = data[col_name].astype(float) + arr = arr[~np.isnan(arr)] + arr = arr[~np.isinf(arr)] + + _, ax = plt.subplots() + sns.histplot( + arr, + color="gray", + alpha=0.6, + ax=ax, + kde=True, + stat="density", + label=col_name, + ) + for x_coord in bar_coords or []: + 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) + + return (histogram_file, ) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook( + plotting=True + ), + LogRuntimeHook( + bunched=False + ), + PythonWrapperHook(), + ] +) +def plot_brainparc( + wm_mask_file: File, + gm_mask_file: File, + csf_mask_file: File, + brain_mask_file: File, + output_dir: Directory, + entities: dict, + dryrun: bool = False) -> tuple[File]: + """ + + Parameters + ---------- + wm_mask_file : File + Binary mask of white matter regions. + gm_mask_file : File + Binary mask of gray matter regions. + csf_mask_file : File + Binary mask of cerebrospinal fluid regions. + brain_mask_file : File + Binary brain mask file. + output_dir : Directory + 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 + ------- + brainparc_image_file : File + Image of the GM mask and GM, WM, CSF tissues histograms. + """ + basename = "sub-{sub}_ses-{ses}_run-{run}_brainparc".format( + **entities) + brainparc_image_file = output_dir / f"{basename}.png" + + if dryrun: + return (brainparc_image_file, ) + + subject = f"run-{entities['run']}" + anat_file = output_dir.parent / subject / "mri" / "norm.mgz" + + fig, axs = plt.subplots(2) + plotting.plot_roi( + roi_img=gm_mask_file, + bg_img=anat_file, + alpha=0.3, + figure=fig, + axes=axs[0], + ) + + anat_arr = nibabel.load(anat_file).get_fdata() + mask_arr = nibabel.load(brain_mask_file).get_fdata() + bins = np.histogram_bin_edges( + anat_arr[mask_arr.astype(bool)], + bins="auto", + ) + palette = itertools.cycle(sns.color_palette("Set1")) + for name, path in [("WM", wm_mask_file), + ("GM", gm_mask_file), + ("CSF", csf_mask_file)]: + mask = nibabel.load(path).get_fdata() + sns.histplot( + anat_arr[mask.astype(bool)], + bins=bins, + color=next(palette), + alpha=0.6, + ax=axs[1], + kde=True, + stat="density", + label=name, + ) + axs[1].spines["right"].set_visible(False) + axs[1].spines["top"].set_visible(False) + axs[1].legend() + + plt.subplots_adjust(wspace=0, hspace=0, top=0.9, bottom=0.1) + plt.savefig(brainparc_image_file) + + return (brainparc_image_file, ) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook( + plotting=True + ), + LogRuntimeHook( + bunched=False + ), + PythonWrapperHook(), + ] +) +def plot_pca( + pca_file: File, + output_dir: Directory, + dryrun: bool = False) -> tuple[File]: + """ + Plot the two first PCA components. + + Parameters + ---------- + pca_file : File + TSV file containing PCA two first components as two columns named + ``pc1`` and ``pc2``, as well as BIDS ``participant_id``, ``session``, + and ``run``. + output_dir : Directory + Directory where the result image will be saved. + dryrun : bool + If True, skip actual computation and file writing. Default False. + + Returns + ------- + pca_image_file : File + Generated image with the two first PCA components. + """ + pca_image_file = output_dir / f"pca.png" + + if dryrun: + return (pca_image_file, ) + + df = pd.read_csv(pca_file, sep="\t") + + 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]), + 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})") + plt.axis("equal") + ax.spines["right"].set_visible(False) + ax.spines["top"].set_visible(False) + plt.tight_layout() + plt.savefig(pca_image_file) + plt.close(fig) + + return (pca_image_file, ) diff --git a/brainprep/interfaces/qualcheck.py b/brainprep/interfaces/qualcheck.py new file mode 100644 index 00000000..0e2fcb0a --- /dev/null +++ b/brainprep/interfaces/qualcheck.py @@ -0,0 +1,909 @@ +########################################################################## +# 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. +########################################################################## + +""" Quality check functions. +""" + +import glob +from pathlib import Path + +import nibabel +import numpy as np +import pandas as pd +from scipy.stats import ( + entropy, + pearsonr, +) +from sklearn.decomposition import IncrementalPCA + +from ..decorators import ( + CoerceparamsHook, + LogRuntimeHook, + OutputdirHook, + PythonWrapperHook, + step, +) +from ..typing import ( + Directory, + File, +) +from ..utils import ( + coerce_to_path, + parse_bids_keys, + print_warn, +) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook( + quality_check=True + ), + LogRuntimeHook( + bunched=False + ), + PythonWrapperHook(), + ] +) +def network_entropy( + network_files_regex: str, + output_dir: Directory, + entropy_threshold: float = 12, + dryrun: bool = False) -> tuple[File]: + """ + Comput enetwork entropy - "structure vs randomness" metric. + + A commonly used metric to assess whether a connectivity matrix exhibits + meaningful structure is the matrix entropy. Low entropy indicates a + highly structured matrix, whereas high entropy suggests a random-like + or unstructured pattern. + + Parameters + ---------- + network_files_regex : str + A regular expression matching TSV files generated by + ``func_vol_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. + dryrun : bool + If True, skip actual computation and file writing. Default False. + + Returns + ------- + entropy_file : File + A TSV file containing the network entropy for each subject/session/run. + The table includes the columns ``participant_id``, ``session``, + ``run``, and ``entropy``, as well as a binary ``qc`` column + indicating the quality control result. + + Notes + ----- + A ``qc`` column is added to the output table. It contains a binary flag + indicating whether the entropy score do not exceeds the threshold: + ``qc = 1`` if ``entropy < entropy_threshold``, otherwise ``qc = 0``. + """ + entropy_file = output_dir / "network_entropy.tsv" + + if dryrun: + return (entropy_file, ) + + network_files = coerce_to_path( + glob.glob(str(network_files_regex)), + expected_type=list[File], + ) + + scores = pd.DataFrame( + columns=( + "participant_id", + "session", + "run", + "entropy", + ) + ) + for path in network_files: + entities = parse_bids_keys(path) + df = pd.read_csv(path, sep="\t", index_col=0) + + data = np.abs(df.values).flatten() + data /= data.sum() + network_entropy = entropy(data) + + scores.loc[len(scores)] = [ + entities["sub"], + entities["ses"], + entities["run"], + network_entropy, + ] + + scores["qc"] = ( + scores["entropy"] < entropy_threshold + ).astype(int) + + scores = scores.sort_values(by=["participant_id", "session", "run"]) + scores.to_csv( + entropy_file, + index=False, + sep="\t", + ) + + return (entropy_file, ) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook( + quality_check=True + ), + LogRuntimeHook( + bunched=False + ), + PythonWrapperHook(), + ] +) +def mask_overlap( + maskdiff_files_regex: str, + output_dir: Directory, + overlap_threshold: float = 0.05, + dryrun: bool = False) -> tuple[File]: + """ + Compute overlap ratios between mask pairs from `maskdiff` summary files. + + This function loads TSV files produced by the ``maskdiff`` tool, extracts + voxel counts for mask 2 and masks intersection, and computes the overlap + ratio defined as: + + overlap = intersection_size / mask2_size + + Parameters + ---------- + maskdiff_files_regex : str + A regular expression matching TSV files generated by ``maskdiff``. + These files must contain voxel counts and physical volumes (mm³) for + each mask and their intersection. + output_dir : Directory + 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. + dryrun : bool + If True, skip actual computation and file writing. Default False. + + Returns + ------- + overlap_file : File + A TSV file containing the overlap ratio between the intersection and + the second mask for each subject/session/run. The table includes + the columns ``participant_id``, ``session``, ``run``, and + ``mask_size``, ``intersection_size``, ``overlap``, as well as a + binary ``qc`` column indicating the quality control result. + + Notes + ----- + A ``qc`` column is added to the output table. It contains a binary flag + 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" + + if dryrun: + return (overlap_file, ) + + score_files = coerce_to_path( + glob.glob(str(maskdiff_files_regex)), + expected_type=list[File], + ) + + scores = pd.DataFrame( + columns=( + "participant_id", + "session", + "run", + "mask_size", + "intersection_size", + "overlap", + ) + ) + for path in score_files: + entities = parse_bids_keys(path) + df = pd.read_csv(path, sep="\t") + + mask_size = df.loc[df["mask"] == "intersection", "voxels"].iloc[0] + intersection_size = df.loc[df["mask"] == "mask2", "voxels"].iloc[0] + overlap = mask_size / intersection_size + + scores.loc[len(scores)] = [ + entities["sub"], + entities["ses"], + entities["run"], + mask_size, + intersection_size, + overlap, + ] + + scores["qc"] = ( + scores["overlap"] < overlap_threshold + ).astype(int) + + scores = scores.sort_values(by=["participant_id", "session", "run"]) + scores.to_csv( + overlap_file, + index=False, + sep="\t", + ) + + return (overlap_file, ) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook( + quality_check=True + ), + LogRuntimeHook( + bunched=False + ), + PythonWrapperHook(), + ] +) +def mean_correlation( + image_files_regex: str, + atlas_file: File, + output_dir: Directory, + correlation_threshold: float = 0.5, + dryrun: bool = False) -> tuple[File]: + """ + Compute the mean Pearson correlation between a reference image and a list + of other images. + + Parameters + ---------- + image_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. + 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. + dryrun : bool + If True, skip actual computation and file writing. Default False. + + Returns + ------- + correlations_file : File + A TSV file containing the Pearson correlation coefficient between the + atlas image and each image pointed by the input REGEX. The table + includes the columns ``participant_id``, ``session``, ``run``, and + ``mean_correlation``, as well as a binary ``qc`` column indicating + the quality control result. + + Raises + ------ + ValueError + If the atlas and an image have incompatible shape or geometry. + + Notes + ----- + A ``qc`` column is added to the output table. It contains a binary flag + indicating whether the mean correlation score exceeds the threshold: + ``qc = 1`` if ``mean_correlation > correlation_threshold``, + otherwise ``qc = 0``. + """ + correlations_file = output_dir / "mean_correlations.tsv" + + if dryrun: + return (correlations_file, ) + + image_files = coerce_to_path( + glob.glob(str(image_files_regex)), + expected_type=list[File], + ) + atlas_im = nibabel.load(atlas_file) + atlas_arr = atlas_im.get_fdata() + + scores = pd.DataFrame( + columns=( + "participant_id", + "session", + "run", + "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 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(), + ) + scores.loc[len(scores)] = [ + entities["sub"], + entities["ses"], + entities["run"], + corr, + ] + + scores["qc"] = ( + scores["mean_correlation"] > correlation_threshold + ).astype(int) + scores = scores.sort_values(by=["participant_id", "session", "run"]) + scores.to_csv( + correlations_file, + index=False, + sep="\t", + ) + + return (correlations_file, ) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook( + quality_check=True + ), + LogRuntimeHook( + bunched=False + ), + PythonWrapperHook(), + ] +) +def incremental_pca( + image_files_regex: str, + output_dir: Directory, + batch_size: int = 10, + 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. + + The function loads all images matching the provided regex, splits them + into batches, and incrementally fits a PCA model using scikit-learn's + ``IncrementalPCA``. Each image is flattened into a 1D vector before + processing. After fitting, the function transforms all batches to obtain + the first two principal components for each image. These components are + saved in a TSV file as two columns named ``pc1`` and ``pc2``. BIDS + entities (``participant_id``, ``session``, ``run``) are extracted from + filenames using ``parse_bids_keys`` and included in the output table. + + Parameters + ---------- + image_files_regex : str + A REGEX to image files, each representing an image, + all images must have the same size. + output_dir : Directory + Directory where a TSV file containing the values of the first two + components created by the PCA ill be saved, a Directory containing + 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. + dryrun : bool + If True, skip actual computation and file writing. Default False. + + Returns + ------- + pca_file : File + Path to the generated ``pca.tsv`` file containing the PCA results. + + Raises + ------ + ValueError + If no image matches the regex pattern. + If the dataset contains fewer than 2 images, which prevents PCA + computation. + """ + pca_file = output_dir / "pca.tsv" + + if dryrun: + return (pca_file, ) + + image_files = glob.glob(str(image_files_regex)) + n_images = len(image_files) + if n_images == 0: + raise ValueError( + f"No image matches the regex pattern: {image_files_regex}" + ) + if n_images < 2: + raise ValueError( + f"The dataset contains fewer than 2 images: {n_images}" + ) + batches = [ + image_files[idx:idx + batch_size] + for idx in range(0, len(image_files), batch_size) + ] + + pca = IncrementalPCA(n_components=2) + for batch_files in batches: + data = [ + nibabel.load(file_).get_fdata().flatten() + for file_ in batch_files + ] + pca.partial_fit(data) + + df = [] + for batch_files in batches: + data = [ + nibabel.load(file_).get_fdata().flatten() + for file_ in batch_files + ] + components = pca.transform(data) + info = [ + parse_bids_keys(Path(file_)) + for file_ in batch_files + ] + partial_df = pd.DataFrame({ + "participant_id": [item["sub"] for item in info], + "session": [item["ses"] for item in info], + "run": [item["run"] for item in info], + "pc1": components[:, 0], + "pc2": components[:, 1], + "explained_variance_ratio_pc1": [ + pca.explained_variance_ratio_[0], + ] * len(info), + "explained_variance_ratio_pc2": [ + pca.explained_variance_ratio_[1], + ] * len(info), + }) + df.append(partial_df) + + df = pd.concat(df, ignore_index=True) + df.to_csv(pca_file, index=False, sep="\t") + + return (pca_file, ) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook( + quality_check=True + ), + LogRuntimeHook( + bunched=False + ), + PythonWrapperHook(), + ] +) +def euler_numbers( + output_dir: Directory, + euler_threshold: int = -217, + dryrun: bool = False) -> tuple[File]: + """ + FreeSurfer recon-all quality control. + + Parse the FreeSurfer's Euler numbers for all subjects and applies the + quality control described in :footcite:p:`rosen2018euler`. + + Parameters + ---------- + output_dir : Directory + FreeSurfer working directory containing all the subjects. + euler_threshold : int + Quality control threshold on the Euler number. Default 217. + dryrun : bool + If True, skip actual computation and file writing. Default False. + + Returns + ------- + euler_numbers_file : File + A TSV file containing FreeSurfer's averaged Euler numbers. The table + includes the columns ``participant_id``, ``session``, ``run``, and + ``euler_number``, as well as a binary ``qc`` column indicating + the quality control result. + + Notes + ----- + A ``qc`` column is added to the output table. It contains a binary flag + indicating whether the euler score exceeds the threshold: + ``qc = 1`` if ``euler_number > euler_threshold``, + otherwise ``qc = 0``. + + References + ---------- + + .. footbibliography:: + """ + euler_numbers_file = output_dir / "euler_numbers.tsv" + + if dryrun: + return (euler_numbers_file, ) + + log_files = glob.glob(str( + output_dir.parent / + "subjects" / + "sub-*" / + "ses-*" / + "run-*" / + "scripts" / + "recon-all.log" + )) + log_files = [ + Path(item) for item in log_files + ] + + scores = pd.DataFrame( + columns=( + "participant_id", + "session", + "run", + "euler_number", + ) + ) + for path in log_files: + entities = parse_bids_keys(path, full_path=True) + lines = path.read_text().splitlines() + selection = [item for item in lines + if item.startswith("orig.nofix lheno")] + if len(selection) != 1: + print_warn(f"no Euler number: {entities}") + euler_number = float("-inf") + else: + (_, left_euler_number, + right_euler_number) = selection[0].split("=") + left_euler_number, _ = left_euler_number.split(",") + euler_number = 0.5 * ( + int(left_euler_number.strip()) + + int(right_euler_number.strip()) + ) + scores.loc[len(scores)] = [ + entities["sub"], + entities["ses"], + entities["run"], + euler_number, + ] + + scores["qc"] = (scores["euler_number"] > euler_threshold).astype(int) + scores = scores.sort_values(by=["participant_id", "session"]) + scores.to_csv( + euler_numbers_file, + index=False, + sep="\t", + ) + + return (euler_numbers_file, ) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook( + quality_check=True + ), + LogRuntimeHook( + bunched=False + ), + PythonWrapperHook(), + ] +) +def vbm_metrics( + output_dir: Directory, + ncr_threshold: float = 4.5, + iqr_threshold: float = 4.5, + dryrun: bool = False) -> tuple[File]: + """ + CAT12 VBM quality control. + + The following quality metrics are considered: + + - Image Correlation Ratio (ICR) - Measures how well the subject's image + aligns with a reference template. High ICR suggests good + registration quality. + - Noise to Contrast Ratio (NCR) - Assesses image clarity by comparing + noise levels to tissue contrast. Lower NCR may indicate poor image + quality. + - Image Quality Rating (IQR) - A composite score summarizing overall + scan quality. Combines multiple metrics like noise, resolution, + and bias field. Higher IQR = better quality. + + Parse these metrics and applies the quality control described in + :footcite:p:`dufumier2022openbhb`. + + Parameters + ---------- + output_dir : Directory + Working directory containing the outputs. + ncr_threshold : float + Quality control threshold on the NCR scores. Default 4.5. + iqr_threshold : float + Quality control threshold on the IQR scores. Default 4.5. + dryrun : bool + If True, skip actual computation and file writing. Default False. + + Returns + ------- + group_stats_file : File + A TSV file containing quality metrics. The table includes the columns + ``participant_id``, ``session``, ``run``, and ``ICR``, ``NCR``, + ``IQR``, as well as a binary ``qc`` column indicating the quality + control result. + + Notes + ----- + A ``qc`` column is added to the output table. It contains a binary flag + indicating whether the NCR and ICR scores do not exceed input thresholds: + ``qc = 1`` if ``NCR < ncr_threshold & ICR < icr_threshold``, + otherwise ``qc = 0``. + + References + ---------- + + .. footbibliography:: + """ + group_stats_file = output_dir / "group_stats.tsv" + + if dryrun: + return (group_stats_file, ) + + report_files = coerce_to_path( + glob.glob(str( + output_dir.parent / + "subjects" / + "sub-*" / + "ses-*" / + "report" / + "cat_*T1w.xml" + )), + expected_type=list[File] + ) + entities = [ + parse_bids_keys(path) + for path in report_files + ] + + stats = [] + for info, path in zip(entities, report_files, strict=True): + df = pd.read_xml( + path, + iterparse={"qualityratings": ["ICR", "NCR", "IQR"]}, + ) + df.insert(0, "participant_id", info["sub"]) + df.insert(1, "session", info["ses"]) + df.insert(2, "run", info["run"]) + stats.append(df) + df = pd.concat(stats) + df.sort_values(by=["participant_id"], inplace=True) + + df["qc"] = ( + (df["NCR"] < ncr_threshold) & + (df["IQR"] < iqr_threshold) + ).astype(int) + + df.to_csv( + group_stats_file, + index=False, + sep="\t", + ) + + return (group_stats_file, ) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook( + quality_check=True + ), + LogRuntimeHook( + bunched=False + ), + PythonWrapperHook(), + ] +) +def fmriprep_metrics( + output_dir: Directory, + fd_mean_threshold: float = 0.2, + dvars_std_threshold: float = 1.5, + dryrun: bool = False) -> tuple[File]: + """ + FMRIprep quality control. + + The following quality metrics are considered: + + - ``fd_mean`` : mean framewise displacement, a measure of head motion + across the time series. + - ``dvars_std`` : mean standardized DVARS, quantifying the rate of change + in BOLD signal intensity between consecutive volumes. + + Parameters + ---------- + output_dir : Directory + Working directory containing the outputs. + fd_mean_threshold : float + Quality control threshold on the Mean Framewise Displacement (mm). + Default 0.2. + dvars_std_threshold : float + Quality control threshold on the Standardized DVARS. + Default 1.5. + dryrun : bool + If True, skip actual computation and file writing. Default False. + + Returns + ------- + group_stats_file : File + A TSV file containing quality metrics. The table includes the columns + ``participant_id``, ``session``, ``run``, and ``fd_mean``, + ``dvars_std``, as well as a binary ``qc`` column indicating the + quality control result. + + - ``participant_id`` : subject identifier + - ``fd_mean`` : mean framewise displacement + - ``dvars_std`` : mean standardized DVARS + - ``qc`` : quality check output + + Notes + ----- + Missing metrics (e.g., ``snr`` or ``aor``) are filled with ``NaN``. + """ + group_stats_file = output_dir / "motion_confounds.tsv" + + if dryrun: + return (group_stats_file, ) + + report_files = glob.glob(str( + output_dir.parent / "subjects" / "sub-*" / "ses-*" / "func" / + "*desc-confounds_timeseries.tsv" + )) + entities = [ + parse_bids_keys(Path(path)) + for path in report_files + ] + + def safe_mean(df, col): + if col not in df: + return np.nan + return ( + df[col] + .replace("n/a", np.nan) + .astype(float) + .mean() + ) + + stats = [] + for info, path in zip(entities, report_files, strict=True): + df = pd.read_csv( + path, + sep="\t", + ) + stats.append(pd.DataFrame.from_dict( + { + "participant_id": [info["sub"]], + "session": [info["ses"]], + "run": [info["run"]], + "fd_mean": [safe_mean(df, "framewise_displacement")], + "dvars_std": [safe_mean(df, "std_dvars")], + } + )) + + df = pd.concat(stats) + df.sort_values(by=["participant_id"], inplace=True) + + df["qc"] = ( + (df["fd_mean"] < fd_mean_threshold) & + (df["dvars_std"] < dvars_std_threshold) + ).astype(int) + + df.to_csv( + group_stats_file, + index=False, + sep="\t", + ) + + return (group_stats_file, ) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook( + quality_check=True + ), + LogRuntimeHook( + bunched=False + ), + PythonWrapperHook(), + ] +) +def mriqc_metrics( + iqm_files: list[File], + output_dir: Directory, + dryrun: bool = False) -> list[File]: + """ + Filter MRIQC group-level metrics according to modality-specific defaults. + + Each input file must follow the naming pattern ``group_.tsv``. + Based on the modality extracted from the filename (e.g., T1w, bold, dwi), + a predefined set of uncorrelated Image Quality Metrics (IQMs) is selected. + The function then writes a filtered TSV file containing only those metrics + to the specified output directory. + + Parameters + ---------- + iqm_files : list[File] + Paths to MRIQC group-level metrics files. + output_dir : Directory + Directory where the filtered metrics will be written. + dryrun : bool + If True, skip actual computation and file writing. Default False. + + Returns + ------- + filter_iqm_files : list[File] + Paths to the selected set of uncorrelated group-level IQMs. + + Raises + ------ + ValueError + If modality extracted from the filename is unsupported + If the filename does not follow the expected pattern + ``group_.tsv``. + """ + filter_iqm_files = [ + output_dir / f"filter_{path.name}" + for path in iqm_files + ] + + if dryrun: + return (iqm_files, ) + + selected_metrics = { + "T1w": [ + "cjv", "cnr", "efc", "fber", "wm2max", "inu_med", "qi_1", "qi_2", + "icvs_wm", "fwhm_avg", "rpve_wm", "snr_wm", "snrd_wm", "wm2max", + ], + "bold": [ + "aor", "aqi", "dummy_trs", "dvars_vstd", "efc", "fber", + "fd_mean", "fwhm_avg", "gcor", "gsr_x", "gsr_y", "snr", "tsnr", + ], + "dwi": [ + "bdiffs_max", "bdiffs_median", "efc_shell01", "efc_shell02", + "fber_shell01", "fber_shell02", "fd_mean", "ndc", "sigma_pca", + "sigma_piesno", "snr_cc_shell0", "snr_cc_shell1_best", + "snr_cc_shell1_worst", "spikes_global", + ] + } + for input_file, output_file in zip( + iqm_files, filter_iqm_files, strict=True): + + if not input_file.stem.startswith("group_"): + raise ValueError( + f"Invalid filename '{input_file.name}': expected " + "'group_.tsv'." + ) + + df = pd.read_csv(input_file, sep="\t") + + modality = input_file.stem.replace("group_", "") + if modality not in selected_metrics: + raise ValueError( + f"Unsupported modality '{modality}'. " + f"Expected one of: {', '.join(selected_metrics)}." + ) + + df = df[["bids_name", *selected_metrics[modality]]] + df.to_csv(output_file, sep="\t", index=False) + + return (filter_iqm_files, ) diff --git a/brainprep/interfaces/utils.py b/brainprep/interfaces/utils.py new file mode 100644 index 00000000..ff4dcd33 --- /dev/null +++ b/brainprep/interfaces/utils.py @@ -0,0 +1,401 @@ +########################################################################## +# 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. +########################################################################## + +""" +Utils functions. +""" + +import getpass +import gzip +import shutil +import socket +from collections import OrderedDict + +import nibabel +import numpy as np +import pandas as pd + +from ..decorators import ( + CoerceparamsHook, + CommandLineWrapperHook, + LogRuntimeHook, + OutputdirHook, + PythonWrapperHook, + step, +) +from ..typing import ( + Directory, + File, +) +from ..utils import ( + make_run_id, +) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + 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(), + ] +) +def copyfiles( + source_image_files: list[File], + destination_image_files: list[File], + output_dir: Directory, + dryrun: bool = False) -> None: + """ + Copy input image files. + + 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. + output_dir : Directory + Directory where the images are copied. + dryrun : bool + 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): + shutil.copy(src_path, dest_path) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + bunched=False + ), + PythonWrapperHook(), + ] +) +def movedir( + source_dir: Directory, + output_dir: Directory, + content: bool = False, + dryrun: bool = False) -> tuple[Directory]: + """ + Move input directory. + + Parameters + ---------- + source_dir : Directory + Path to the directory to be moved. + output_dir : Directory + Directory where the folder is moved. + content : bool + If True, move the content of the source directory. Default False. + dryrun : bool + If True, skip actual computation and file writing. Default False. + + Returns + ------- + target_directory : Directory + Path to the moved directory. + + Raises + ------ + 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) + 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, ) + + +@step( + hooks=[ + CoerceparamsHook(), + LogRuntimeHook( + bunched=False + ), + PythonWrapperHook(), + ] +) +def ungzfile( + input_file: File, + output_file: File, + output_dir: Directory, + dryrun: bool = False) -> tuple[File]: + """ + Ungzip input file. + + Parameters + ---------- + input_file : File + Path to the file to ungzip. + output_file : File + Path to the ungzip file. + output_dir : Directory + Directory where the unzip file is created. + dryrun : bool + If True, skip actual computation and file writing. Default False. + + Returns + ------- + output_file : File + Path to the ungzip file. + + Raises + ------ + ValueError + If the input file is not compressed. + """ + if input_file.suffix != ".gz": + raise ValueError( + f"The input file is not compressed: {input_file}" + ) + + if not dryrun: + with gzip.open(input_file, "rb") as gzfobj: + output_file.write_bytes(gzfobj.read()) + + return (output_file, ) + + +@step( + hooks=[ + CoerceparamsHook(), + OutputdirHook(), + LogRuntimeHook( + bunched=False + ), + ] +) +def write_uuid_mapping( + input_file: File, + output_dir: Directory, + entities: dict, + name: str = "uuid_mapping.tsv", + full_path: bool = False) -> File: + """ + Create a TSV file that records a deterministic UUID-based mapping. + + Each row contains: + - filename: relative path within the BIDS dataset. + - run_default: 5-digit deterministic run ID derived from the filename. + - uuid: full UUIDv5 for traceability. + + Parameters + ---------- + input_file : File + Path to the file to map. + output_dir : Directory + Directory where the TSV file is created. + entities : dict + A dictionary of parsed BIDS entities including modality. + name : str + 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. + + Returns + ------- + output_file : File + Path to the written TSV file. + """ + outut_file = output_dir / f"run-{entities['run']}" / name + filename = str(input_file) if full_path else input_file.name + code, short_code = make_run_id(filename) + + if short_code == entities["run"]: + outut_file.parent.mkdir(parents=True, exist_ok=True) + df = pd.DataFrame.from_dict({ + "filename": [filename], + "run": [short_code], + "uuid": [code], + }) + df.to_csv(outut_file, sep="\t", index=False) + else: + outut_file = None + + return (outut_file, ) + + +@step( + hooks=[ + CoerceparamsHook(), + LogRuntimeHook( + bunched=False + ), + CommandLineWrapperHook(), + ] +) +def anonfile( + input_file: File, + mapping: dict[str, str]) -> 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. + + Parameters + ---------- + input_file : File + Path to the file to anonymize. + mapping : dict[str, str] + Patterns to replace (keys) and their replacements (values). + + Returns + ------- + command : list[str] + The sed command-line used for anonization. + 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", + }) + ) + + patterns = [] + for old, new in mapping.items(): + old_esc = old.replace("/", r"\/") + new_esc = new.replace("/", r"\/") + patterns.extend(["-e", f"'s/{old_esc}/{new_esc}/g'"]) + command = [ + "sed", + *patterns, + "-i", str(input_file) + ] + + return command, (input_file, ) diff --git a/brainprep/plotting.py b/brainprep/plotting.py deleted file mode 100644 index 28cf8f23..00000000 --- a/brainprep/plotting.py +++ /dev/null @@ -1,232 +0,0 @@ -# -*- coding: utf-8 -*- -########################################################################## -# NSAp - Copyright (C) CEA, 2021 - 2022 -# 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. -########################################################################## - -""" -Usefull plotting functions. -""" - -# Imports -import os -import nibabel -import itertools -import numpy as np -import progressbar -from nilearn import plotting -import matplotlib.pyplot as plt -import seaborn as sns -from .utils import get_bids_keys - - -def plot_images(nii_files, cut_coords, outdir): - """ Plot images on a subject basis. - - Parameters - ---------- - img_files: list of n-uplet (n_subjects, n_path) - path to images. - cut_coords: list of int (n_path, 3) - the MNI coordinates of the point where the orthogonal cut is performed. - outdir: str - the destination folder. - - Returns - ------- - snaps: list of str - the generated snaps. - snapdir: str - the folder that contains all results. - """ - snapdir = os.path.join(outdir, "snap") - if not os.path.isdir(snapdir): - os.mkdir(snapdir) - snaps = [] - with progressbar.ProgressBar(max_value=len(nii_files)) as bar: - for cnt, data in enumerate(nii_files): - fig, axs = plt.subplots(len(data)) - for idx, (path, cut) in enumerate(zip(data, cut_coords)): - img = nibabel.load(path) - if not isinstance(axs, list): - axs = [axs] - plotting.plot_anat(img, figure=fig, axes=axs[idx], - cut_coords=cut, display_mode="ortho") - plt.subplots_adjust(wspace=0, hspace=0, top=0.9, bottom=0.1) - keys = get_bids_keys(path) - participant_id = keys["participant_id"] - session = keys["session"] - run = keys["run"] - snap_path = os.path.join( - snapdir, "sub-{}_ses-{}_run-{}_snaps.png".format( - participant_id, session, run)) - plt.savefig(snap_path) - snaps.append(snap_path) - bar.update(cnt) - return snaps, snapdir - - -def plot_hists(data, outdir, title=None): - """ Plot hisograms with optional vertical bars. - - Parameters - ---------- - data: dict - containes the data to display in 'data' and optionnaly the coordianate - of the vertical line in 'bar_low' and 'bar_up'. - outdir: str - the destination folder. - title: str, default None - title of the histogram. - - Returns - ------- - snap: str - the generated snap. - """ - fig, axs = plt.subplots(len(data)) - if not isinstance(axs, np.ndarray): - axs = [axs] - for cnt, (name, item) in enumerate(data.items()): - arr = item["data"].astype(np.single) - arr = arr[~np.isnan(arr)] - arr = arr[~np.isinf(arr)] - sns.histplot(arr, color="gray", alpha=0.6, ax=axs[cnt], - kde=True, stat="density", label=name) - coord_low = item.get("bar_low") - coord_up = item.get("bar_up") - if coord_low is not None: - axs[cnt].axvline(x=coord_low, color="red") - if coord_up is not None: - axs[cnt].axvline(x=coord_up, color="red") - axs[cnt].spines["right"].set_visible(False) - axs[cnt].spines["top"].set_visible(False) - axs[cnt].legend() - plt.subplots_adjust(wspace=0, hspace=0, top=0.9, bottom=0.1) - if title is not None: - plt.title(title) - snap_path = os.path.join(outdir, f"hists_{title}.png") - else: - snap_path = os.path.join(outdir, "hists.png") - plt.savefig(snap_path) - return snap_path - - -def plot_fsreconall(fs_dirs, outdir, include_cerebellum=False): - """ Plot images on a subject basis. - - Parameters - ---------- - fs_dirs: list of str - list of FreeSurfer recon-all generated directories. - outdir: str - the destination folder. - include_cerebellum: bool, default False - include the cerebellum as a structure of interest. - - Returns - ------- - snaps: list of str - the generated snaps. - snapdir: str - the folder that contains all results. - """ - snapdir = os.path.join(outdir, "snap") - if not os.path.isdir(snapdir): - os.mkdir(snapdir) - snaps = [] - with progressbar.ProgressBar(max_value=len(fs_dirs)) as bar: - for cnt1, path in enumerate(fs_dirs): - fig, axs = plt.subplots(2) - ribbon_file = os.path.join(path, "mri", "ribbon.mgz") - wmparc_file = os.path.join(path, "mri", "wmparc.mgz") - anat_file = os.path.join(path, "mri", "norm.mgz") - ribbon_im = nibabel.load(ribbon_file) - wmparc_im = nibabel.load(wmparc_file) - wm_mask, gm_mask, csf_mask, brain_mask = get_fsreconall_masks( - ribbon_im.get_fdata(), wmparc_im.get_fdata(), - include_cerebellum=include_cerebellum) - anat_im = nibabel.load(anat_file) - anat_arr = anat_im.get_fdata() - gm_im = nibabel.Nifti1Image( - gm_mask.astype(int), affine=ribbon_im.affine) - plotting.plot_roi(roi_img=gm_im, bg_img=anat_im, alpha=0.3, - figure=fig, axes=axs[0]) - palette = itertools.cycle(sns.color_palette("Set1")) - bins = np.histogram_bin_edges(anat_arr[brain_mask], bins="auto") - for name, mask in [("WM", wm_mask), ("GM", gm_mask), - ("CSF", csf_mask)]: - sns.histplot(anat_arr[mask], bins=bins, color=next(palette), - alpha=0.6, ax=axs[1], kde=True, - stat="density", label=name) - axs[1].spines["right"].set_visible(False) - axs[1].spines["top"].set_visible(False) - axs[1].legend() - plt.subplots_adjust(wspace=0, hspace=0, top=0.9, bottom=0.1) - keys = get_bids_keys(path) - participant_id = keys["participant_id"] - session = keys["session"] - run = keys["run"] - snap_path = os.path.join( - snapdir, "sub-{}_ses-{}_run-{}_snaps.png".format( - participant_id, session, run)) - plt.savefig(snap_path) - snaps.append(snap_path) - bar.update(cnt1) - return snaps, snapdir - - -def get_fsreconall_masks(ribbon_arr, wmparc_arr, include_cerebellum=False): - """ Return the WM, GM, CSF, and brain binary masks. - """ - # - Left-Cerebral-White-Matter, Right-Cerebral-White-Matter - ribbon_wm_structures = [2, 41] - # - Left-Cerebral-Cortex, Right-Cerebral-Cortex - ribbon_gm_structures = [3, 42] - # - Fornix, CC-Posterior, CC-Mid-Posterior, CC-Central, CC-Mid-Anterior, - # CC-Anterior - wmparc_cc_structures = [250, 251, 252, 253, 254, 255] - # - Left-Lateral-Ventricle, Left-Inf-Lat-Vent, 3rd-Ventricle, - # 4th-Ventricle, CSF Left-Choroid-Plexus, Right-Lateral-Ventricle, - # Right-Inf-Lat-Vent, Right-Choroid-Plexus - wmparc_csf_structures = [4, 5, 14, 15, 24, 31, 43, 44, 63] - if include_cerebellum: - # - Cerebellar-White-Matter-Left, Brain-Stem, - # Cerebellar-White-Matter-Right - wmparc_wm_structures = [7, 16, 46] - # - Left-Cerebellar-Cortex, Right-Cerebellar-Cortex, Thalamus-Left, - # Caudate-Left, Putamen-Left, Pallidum-Left, Hippocampus-Left, - # Amygdala-Left, Accumbens-Left, Diencephalon-Ventral-Left, - # Thalamus-Right, Caudate-Right, Putamen-Right, Pallidum-Right, - # Hippocampus-Right, Amygdala-Right, Accumbens-Right, - # Diencephalon-Ventral-Right - wmparc_gm_structures = [8, 47, 10, 11, 12, 13, 17, 18, 26, 28, 49, 50, - 51, 52, 53, 54, 58, 60] - else: - # Omit cerebellum and brain stem - wmparc_wm_structures = [] - wmparc_gm_structures = [10, 11, 12, 13, 17, 18, 26, 28, 49, 50, 51, - 52, 53, 54, 58, 60] - wm_mask = np.logical_and( - np.logical_and( - 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 = np.in1d(wmparc_arr, wmparc_csf_structures) - gm_mask = np.logical_or( - np.in1d(ribbon_arr, ribbon_gm_structures), - np.in1d(wmparc_arr, wmparc_gm_structures)) - wm_mask = np.reshape(wm_mask, ribbon_arr.shape) - csf_mask = np.reshape(csf_mask, ribbon_arr.shape) - gm_mask = np.reshape(gm_mask, ribbon_arr.shape) - brain_mask = np.logical_or( - np.logical_or(wm_mask, gm_mask), - csf_mask) - return wm_mask, gm_mask, csf_mask, brain_mask diff --git a/brainprep/qc.py b/brainprep/qc.py deleted file mode 100644 index 26c0725c..00000000 --- a/brainprep/qc.py +++ /dev/null @@ -1,413 +0,0 @@ -# -*- coding: utf-8 -*- -########################################################################## -# NSAp - Copyright (C) CEA, 2021 - 2022 -# 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. -########################################################################## - -""" -Usefull automatic quality control (QC) functions. -""" - -# Imports -import os -import re -import traceback -import numpy as np -import pandas as pd -from pprint import pprint -import xml.etree.ElementTree as ET -from sklearn.decomposition import PCA -import matplotlib.pyplot as plt -import seaborn as sns -from .utils import get_bids_keys - - -def check_files(input_files): - """ Check if all data are ordered the same way and follows the BIDS - nomenclature. - - Parameters - ---------- - input_files: list of list - """ - sizes = [len(item) for item in input_files] - if len(np.unique(sizes)) != 1: - pprint(input_files) - raise ValueError("Input list of files must have the same number of " - "elements.") - for item in zip(*input_files): - keys = [get_bids_keys(path) for path in item] - keys = ["{participant_id}_{session}_{run}".format(**item) - for item in keys] - if len(np.unique(keys)) != 1: - raise ValueError( - "Input list of files are not ordered the same way.") - - -def plot_pca(X, df_description, outdir): - """ Save the two first PCA components. - - Parameters - ---------- - X: array (n_samples, ...) - the input data. - df_description: pandas DataFrame - samples associated descriptons: must have 'n_samples' rows and a - 'participant_id' column. - outdir: str - the destination folder. - - Returns - ------- - pca_path: str - the path to the generated file. - """ - if len(X) != len(df_description): - raise ValueError("'X' and 'df_description' must have the same length.") - if "participant_id" not in df_description.columns: - raise ValueError("'df_description' must contains a 'participant_id' " - "column.") - X = X.reshape(len(X), -1) - X[np.isnan(X)] = 0 - pca = PCA(n_components=2) - components = pca.fit_transform(X) - fig, ax = plt.subplots(figsize=(20, 30)) - ax.scatter(components[:, 0], components[:, 1]) - for idx, desc in enumerate(df_description["participant_id"]): - ax.annotate(desc, xy=(components[idx, 0], components[idx, 1]), - xytext=(4, 4), textcoords="offset pixels") - plt.xlabel("PC1 (var=%.2f)" % pca.explained_variance_ratio_[0]) - plt.ylabel("PC2 (var=%.2f)" % pca.explained_variance_ratio_[1]) - plt.axis("equal") - ax.spines['right'].set_visible(False) - ax.spines['top'].set_visible(False) - plt.tight_layout() - pca_path = os.path.join(outdir, "pca.pdf") - plt.savefig(pca_path) - return pca_path - - -def compute_mean_correlation(X, df_description, outdir): - """ Compute mean correlation. - - Parameters - ---------- - X: array (n_samples, ...) - the input data. - df_description: pandas DataFrame - samples associated descriptons: must have 'n_samples' rows and - 'participant_id', 'session', 'run' and 'ni_path' columns. - outdir: str - the destination folder. - - Returns - ------- - df_corr: pandas DataFrame - sorted input data description based on mean correlation: columns are - 'participant_id', 'session', 'run', 'corr_mean'. - heatmap_path: str - path to the heatmap of mean correlation. - """ - # Checks - if len(X) != len(df_description): - raise ValueError("'X' and 'df_description' must have the same length.") - for key in ("participant_id", "ni_path", "session", "run"): - if key not in df_description.columns: - raise ValueError( - "'df_description' must contains a '{}' column.".format(key)) - - # Compute the correlation matrix - X = X.reshape(len(X), -1) - X[np.isnan(X)] = 0 - X[np.isinf(X)] = 0 - corr = np.corrcoef(X, dtype=np.single) - - # Compute the Z-transformation of the correlation - den = 1. - corr - den[den == 0] = 1e-8 - zcorr = 0.5 * np.log((1. + corr) / den) - zcorr[np.isnan(zcorr)] = 0 - zcorr[np.isinf(zcorr)] = 0 - zcorr_mean = (zcorr.sum(axis=1) - 1) / (len(zcorr) - 1) - - # Get the index sorted by descending Z-corrected mean correlation values - sort_idx = np.argsort(zcorr_mean) - participant_ids = df_description["participant_id"][sort_idx] - sessions_ids = df_description["session"][sort_idx] - run_ids = df_description["run"][sort_idx] - corr_reorder = corr[np.ix_(sort_idx, sort_idx)] - - # Plot heatmap of mean correlation - plt.subplots(figsize=(10, 10)) - cmap = sns.color_palette("RdBu_r", 110) - sns.heatmap(corr_reorder, mask=None, cmap=cmap, vmin=-1, vmax=1, center=0) - corr_path = os.path.join(outdir, "correlation.png") - plt.savefig(corr_path) - - # Generate data frame with results - df_corr = pd.DataFrame(dict(participant_id=participant_ids, - session=sessions_ids, - run=run_ids, - corr_mean=zcorr_mean[sort_idx])) - df_corr = df_corr.reindex( - ["participant_id", "session", "run", "corr_mean"], axis="columns") - - return df_corr, corr_path - - -def parse_fsreconall_stats(fs_dirs): - """ Parse the FreeSurfer reconall generated quality control files for all - subjects. - - Parameters - ---------- - fs_dirs: list of str - list of FreeSurfer recon-all generated directories. - - Returns - ------- - df_scores: pandas DataFrame - the FreeSurfer recon-all scores organized by 'participant_id', - 'session', 'run', 'euler'. - """ - scores = {} - for path in fs_dirs: - keys = get_bids_keys(path) - participant_id = keys["participant_id"] - session = keys["session"] - run = keys["run"] - logfile = os.path.join(path, "scripts", "recon-all.log") - with open(logfile, "rt") as of: - lines = of.readlines() - selection = [item for item in lines - if item.startswith("orig.nofix lheno")] - assert len(selection) == 1, selection - _, left_euler, right_euler = selection[0].split("=") - left_euler, _ = left_euler.split(",") - left_euler = int(left_euler.strip()) - right_euler = int(right_euler.strip()) - euler = (left_euler + right_euler) * 0.5 - scores.setdefault("participant_id", []).append(participant_id) - scores.setdefault("session", []).append(session) - scores.setdefault("run", []).append(run) - scores.setdefault("euler", []).append(euler) - df_scores = pd.DataFrame.from_dict(scores) - return df_scores - - -def parse_cat12vbm_roi( - xml_filenames, output_file, - iterparse={"neuromorphometrics": ["ids", "Vgm", "Vcsf"]}): - """ Parse the cat12vbm xml generated rois files for all - subjects. - - Parameters - ---------- - xml_filenames: list or str(regex,regex) - regex to the CAT12 VBM catROI and cat xml files for all subjects: - `/label/catROI_sub-*_ses-*_T1w.xml`, - `/report/cat_sub-*_ses-*_T1w.xml`. - output: str - the destination folder. - iterparse: dict - a dictionary with the region type to focus on as key, and the list of - data to return as values. - - Returns - ------- - output_file: str - rois tsv path. - """ - roi_names = None - cohort_globvol = pd.DataFrame() - cohort_roivol = pd.DataFrame() - - for xml_file in xml_filenames: - df_sub_key = pd.DataFrame() - xml_file_keys = get_bids_keys(xml_file) - participant_id = "sub-"+xml_file_keys['participant_id'] - session = xml_file_keys['session'] or '1' - run = xml_file_keys['run'] or '1' - df_sub_key["participant_id"] = [participant_id] - df_sub_key["session"] = [session] - df_sub_key["run"] = [run] - - if (re.match(".*report/cat_.*\.xml", xml_file) and - "avg" not in xml_file): - cat = pd.read_xml(xml_file) - try: - # read the global volumes - tiv = cat['vol_TIV'][7] - vol_abs_cgw = cat['vol_abs_CGW'][7][1:-1].split() - vol_abs_cgw = [float(volume) for volume in vol_abs_cgw] - except Exception as e: - print('Parsing error for %s:\n%s' % - (xml_file, traceback.format_exc())) - else: - # put these volumes in a dataframe - globvolume_dico_sub = {} - globvolume_dico_sub['tiv'] = float(tiv) - globvolume_dico_sub['CSF_Vol'] = vol_abs_cgw[0] - globvolume_dico_sub['GM_Vol'] = vol_abs_cgw[1] - globvolume_dico_sub['WM_Vol'] = vol_abs_cgw[2] - df_global_sub = pd.DataFrame(globvolume_dico_sub, index=[0]) - # concatenate the subject dataframe to the global one - concat_globvol = [df_sub_key, df_global_sub] - sub_globvol = pd.concat(concat_globvol, axis=1) - cohort_globvol = pd.concat([cohort_globvol, sub_globvol], axis=0) - - elif re.match('.*label/catROI_.*\.xml', xml_file): - tree = ET.parse(xml_file) - try: - # read the label xml file - catroi = pd.read_xml(xml_file, iterparse=iterparse) - key = list(iterparse.keys())[0] - # get the ROI names - _roi_names = [item.text for item in - tree.find(key) - .find('names').findall('item')] - if roi_names is None: - roi_names = _roi_names - assert set(roi_names) == set(_roi_names), xml_file - # parse GM, WM and CSF data if needed - if "Vgm" in iterparse[key]: - v_gm = (catroi['Vgm'] - .str.replace("\[|\]", "", regex=True) - .str.split(";")[0]) - v_gm = [float(volume) for volume in v_gm] - assert len(roi_names) == len(v_gm) - if "Vcsf" in iterparse[key]: - v_csf = (catroi['Vcsf'] - .str.replace("\[|\]", "", regex=True) - .str.split(";")[0]) - v_csf = [float(volume) for volume in v_csf] - assert len(roi_names) == len(v_csf) - if "Vwm" in iterparse[key]: - v_wm = (catroi['Vwm'] - .str.replace("\[|\]", "", regex=True) - .str.split(";")[0]) - v_wm = [float(volume) for volume in v_wm] - assert len(roi_names) == len(v_wm) - except Exception as e: - print('Parsing error for %s: \n%s' % - (xml_file, traceback.format_exc())) - else: - rois_sub = {} - gm_rois_names = [rois_name + '_GM_Vol' for rois_name - in roi_names] - wm_rois_names = [rois_name + '_WM_Vol' for rois_name - in roi_names] - csf_rois_names = [rois_name + '_CSF_Vol' for rois_name - in roi_names] - for idx, gmroiname in enumerate(gm_rois_names): - if "Vgm" in iterparse[key]: - rois_sub[gmroiname] = v_gm[idx] - if "Vcsf" in iterparse[key]: - rois_sub[csf_rois_names[idx]] = v_csf[idx] - if "Vwm" in iterparse[key]: - rois_sub[wm_rois_names[idx]] = v_wm[idx] - df_rois_sub = pd.DataFrame(rois_sub, index=[0]) - concat_roivol = [df_sub_key, df_rois_sub] - sub_roivol = pd.concat(concat_roivol, axis=1) - cohort_roivol = pd.concat([cohort_roivol, sub_roivol], axis=0) - roi_names = roi_names or [] - cohort_volumes = cohort_globvol.merge(cohort_roivol, how='outer', - on=['participant_id', 'session', - 'run']) - cohort_volumes.to_csv(output_file, sep="\t", float_format=str, index=False) - return output_file - - -def parse_cat12vbm_qc(qc_files): - """ Parse the CAT12 VBM generated quality control files for all - subjects. - - Parameters - ---------- - qc_files: list of str - list of CAT12 VBM generated quality control xml files. - - Returns - ------- - df_scores: pandas DataFrame - the CAT12 VBM scores organized by 'participant_id', 'session', 'run', - 'NCR', 'ICR', 'IQR'. - """ - scores = {} - for xml_file in qc_files: - keys = get_bids_keys(xml_file) - participant_id = keys["participant_id"] - session = keys["session"] - run = keys["run"] - if re.match(".*report/cat_.*\.xml", xml_file): - tree = ET.parse(xml_file) - try: - ncr = float(tree.find("qualityratings").find("NCR").text) - icr = float(tree.find("qualityratings").find("ICR").text) - iqr = float(tree.find("qualityratings").find("IQR").text) - except Exception as e: - print(e) - trace = traceback.format_exc() - print("Parsing error for {}:\n{}".format(xml_file, trace)) - ncr, icr, iqr = (np.nan, np.nan, np.nan) - scores.setdefault("participant_id", []).append(participant_id) - scores.setdefault("session", []).append(session) - scores.setdefault("run", []).append(run) - scores.setdefault("NCR", []).append(ncr) - scores.setdefault("ICR", []).append(icr) - scores.setdefault("IQR", []).append(iqr) - df_scores = pd.DataFrame.from_dict(scores) - return df_scores - - -def parse_cat12vbm_report(img_files, cat12vbm_root): - """ Parse the CAT12 VBM report files for all subjects. - - Parameters - ---------- - img_files: list of str - path to images. - cat12vbm_root: str - the root path of the CAT12VBM preprocessing folder. - - Returns - ------- - reports: list of str - the associated CAT12 VBM reports. - """ - reports = [] - for path in img_files: - keys = get_bids_keys(path) - participant_id = keys["participant_id"] - session = keys["session"] - name = os.path.basename(path)[4:] - if name.endswith(".nii.gz"): - name = name.replace(".nii.gz", ".pdf") - elif name.endswith(".nii"): - name = name.replace(".nii", ".pdf") - else: - raise ValueError("Unexpected file extension: {}.".format(path)) - rpath = [ - os.path.join( - "sub-{}".format(participant_id), "ses-{}".format(session), - "anat", "report", "catreport_{}".format(name)), - os.path.join( - "sub-{}".format(participant_id), "anat", "report", - "catreport_{}".format(name)), - os.path.join( - "sub-{}".format(participant_id), "ses-{}".format(session), - "anat", "report", "catreport_r{}".format(name)), - None - ] - for _rpath in rpath: - if _rpath is None: - reports.append("") - break - _path = os.path.join(cat12vbm_root, _rpath) - if os.path.isfile(_path): - reports.append(_path) - break - return reports diff --git a/brainprep/reporting/__init__.py b/brainprep/reporting/__init__.py new file mode 100644 index 00000000..8d9b7340 --- /dev/null +++ b/brainprep/reporting/__init__.py @@ -0,0 +1,24 @@ +########################################################################## +# NSAp - Copyright (C) CEA, 2022 - 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. +########################################################################## + +""" +Module that implements reporting tools. +""" + + +from .html_reporting import generate_qc_report +from .rst_reporting import ( + RSTReport, + trace_module_calls, +) + +__all__ = [ + "RSTReport", + "generate_qc_report", + "trace_module_calls", +] diff --git a/brainprep/reporting/data/base.html b/brainprep/reporting/data/base.html new file mode 100644 index 00000000..7a31d836 --- /dev/null +++ b/brainprep/reporting/data/base.html @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + BrainPrepQC + + + + + + + + + + + +
+ +
+
+ {% block content %} + {% endblock content %} +
+
+ + + + + + + + + diff --git a/brainprep/reporting/data/body.html b/brainprep/reporting/data/body.html new file mode 100644 index 00000000..9f70e87b --- /dev/null +++ b/brainprep/reporting/data/body.html @@ -0,0 +1,113 @@ +{% extends "base.html" %} + + +{% block nav %} + +{% for workflow in workflows %} +
  • + {{ workflow.name }} +
  • +{% endfor %} +
  • + About +
  • + +{% endblock nav %} + + +{% block content %} + +
    +
    + +
    +

    Description

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

    +
    
    +            {{ docstring }}
    +        
    +

    +
    + + {% for workflow in workflows %} + +
    + +

    {{ workflow.name }}

    +
    +
    + {% if workflow.content and workflow.content|length == 1 %} + No image provided. + {% if workflow.overlay %} +
    + No overlay found. +
    + {% endif %} + {% elif workflow.content and workflow.content|length > 1 %} +
    + +

    + +

    + +
    + {% for image in workflow.content %} + + {% endfor %} + {% endif %} +
    + {% if workflow.tables %} +
    + + Tables + + {% for table in workflow.tables %} + {{ table }} + {% endfor %} +
    + {% endif %} +
    + +
    + + {% endfor %} + +
    +

    About

    +
      +
    • Date preprocessed:
    • +
    +
    + +
    +
    + +{% endblock content %} + diff --git a/brainprep/reporting/data/script.js b/brainprep/reporting/data/script.js new file mode 100644 index 00000000..1d1e2899 --- /dev/null +++ b/brainprep/reporting/data/script.js @@ -0,0 +1,193 @@ + +/** + * Menu handling. + */ +$(function() { + + var siteSticky = function() { + $(".js-sticky-header").sticky({topSpacing:0}); + }; + siteSticky(); + + var siteMenuClone = function() { + + $('.js-clone-nav').each(function() { + var $this = $(this); + $this.clone().attr('class', 'site-nav-wrap').appendTo('.site-mobile-menu-body'); + }); + + + setTimeout(function() { + + var counter = 0; + $('.site-mobile-menu .has-children').each(function(){ + var $this = $(this); + + $this.prepend('