diff --git a/.circleci/config.yml b/.circleci/config.yml index 055f384..37ccfa4 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -50,6 +50,8 @@ jobs: - run: mkdir -p ${HOME}/outputs<< parameters.dataset >> + - run: sudo apt-get install tree + - run: name: print version command: | @@ -57,35 +59,25 @@ jobs: -v /tmp/workspace/data/ds114_test1:/bids_dataset \ bids/${CIRCLE_PROJECT_REPONAME,,} --version - # participant level tests for single session dataset - - run: - command: | - docker run -ti --rm --read-only \ - -v /tmp/workspace/data/ds114_test<< parameters.dataset >>:/bids_dataset \ - -v ${HOME}/outputs<< parameters.dataset >>:/outputs \ - bids/${CIRCLE_PROJECT_REPONAME,,} /bids_dataset /outputs participant --participant_label 01 - no_output_timeout: 6h - run: + name: participant level test command: | docker run -ti --rm --read-only \ -v /tmp/workspace/data/ds114_test<< parameters.dataset >>:/bids_dataset \ -v ${HOME}/outputs<< parameters.dataset >>:/outputs \ - bids/${CIRCLE_PROJECT_REPONAME,,} /bids_dataset /outputs participant --participant_label 02 - no_output_timeout: 6h + bids/${CIRCLE_PROJECT_REPONAME,,} /bids_dataset /outputs participant + tree ${HOME}/outputs<< parameters.dataset >> - run: + name: group level test command: | docker run -ti --rm --read-only \ -v /tmp/workspace/data/ds114_test<< parameters.dataset >>:/bids_dataset \ - -v ${HOME}/outputs1:/outputs \ + -v ${HOME}/outputs<< parameters.dataset >>:/outputs \ bids/${CIRCLE_PROJECT_REPONAME,,} /bids_dataset /outputs group - no_output_timeout: 6h - - - store_artifacts: - path: ~/output<< parameters.dataset >> + tree ${HOME}/outputs<< parameters.dataset >> deploy: - machine: image: ubuntu-2204:2022.10.2 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..d96f25e --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,28 @@ +--- +name: validate boutiques descriptor + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: [master] + pull_request: + branches: ['*'] + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: 3.12 + - name: Install + run: pip install boutiques + - name: Validate + run: bosh validate boutiques/descriptor.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7b424f4..cd4b32b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,14 +14,12 @@ repos: - id: check-case-conflict - id: check-merge-conflict - - repo: https://github.com/jumanjihouse/pre-commit-hook-yamlfmt rev: 0.2.3 hooks: - id: yamlfmt args: [--mapping, '2', --sequence, '2', --offset, '0'] - - repo: https://github.com/hadolint/hadolint rev: v2.13.0-beta hooks: @@ -32,5 +30,26 @@ repos: types: [dockerfile] entry: ghcr.io/hadolint/hadolint hadolint +- repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + +# Aplly black formatting to python code +# https://github.com/psf/black +- repo: https://github.com/psf/black-pre-commit-mirror + rev: 24.4.2 + hooks: + - id: black + args: [--config, pyproject.toml] + +# Checks for spelling errors +- repo: https://github.com/codespell-project/codespell + rev: v2.2.6 + hooks: + - id: codespell + args: [--toml, pyproject.toml] + additional_dependencies: [tomli] + ci: skip: [hadolint-docker] diff --git a/Dockerfile b/Dockerfile index 1e84b23..8d35fb0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,14 @@ -FROM bids/base_fsl:6.0.1 +FROM bids/base_fsl:6.0.1@sha256:b92b9b4a0642dfbe011c3827384f781ba7d377686e82edaf14f406d3eb906783 ARG DEBIAN_FRONTEND="noninteractive" +COPY requirements.txt /requirements.txt RUN apt-get update -qq && \ apt-get install -q -y --no-install-recommends \ python3 \ python3-pip && \ - pip3 install nibabel==5.1.0 && \ + pip3 install -r /requirements.txt && \ apt-get remove -y python3-pip && \ rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* diff --git a/Singularity b/Singularity index af0f047..6f178ed 100644 --- a/Singularity +++ b/Singularity @@ -2,8 +2,8 @@ Bootstrap: docker From: bids/example %help -You are in the BIDS-example container. To see help run -singularity run BIDS-example.simg -h +You are in the BIDS-example container. +To see help run singularity run: BIDS-example.simg -h %runscript exec /run.py "$@" diff --git a/boutiques/bids-app-example.json b/boutiques/descriptor.json similarity index 97% rename from boutiques/bids-app-example.json rename to boutiques/descriptor.json index 2fbf4da..4a80ddb 100644 --- a/boutiques/bids-app-example.json +++ b/boutiques/descriptor.json @@ -1,15 +1,18 @@ { "author": "chrisfilo and others", + "custom": { + "BIDSApplicationVersion": "2.0" + }, "command-line": "mkdir -p OUTPUT_DIR; /run.py BIDS_DIR OUTPUT_DIR ANALYSIS_LEVEL PARTICIPANT_LABEL SESSION_LABEL", "container-image": { "image": "bids/example", "type": "docker" }, "description": "See https://github.com/BIDS-Apps/example", - "descriptor-url": "https://github.com/BIDS-Apps/example/blob/master/boutiques/bids-app-example.json", + "descriptor-url": "https://github.com/BIDS-Apps/example/blob/master/boutiques/descriptor.json", "groups": [ { - "description": "For a participants analysis, an output directory name must be specified. For a group analysis, a directory containing the output of participant-level analyses must be selected. ", + "description": "For a participants analysis, an output directory name must be specified. For a group analysis, a directory containing the output of participant-level analyses must be selected.", "id": "output_directory", "members": [ "output_dir_name", @@ -54,7 +57,8 @@ "participant", "group" ], - "value-key": "ANALYSIS_LEVEL" + "value-key": "ANALYSIS_LEVEL", + "optional": false }, { "command-line-flag": "--participant_label", diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4ebe735 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,10 @@ +[tool.black] +line-length = 79 + +[tool.codespell] + +[tool.isort] +combine_as_imports = true +line_length = 79 +profile = "black" +skip_gitignore = true diff --git a/requirements.in b/requirements.in new file mode 100644 index 0000000..fa06aea --- /dev/null +++ b/requirements.in @@ -0,0 +1 @@ +nibabel diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c4a15ef --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --strip-extras requirements.in +# +nibabel==5.2.1 + # via -r requirements.in +numpy==1.26.4 + # via nibabel +packaging==24.0 + # via nibabel diff --git a/run.py b/run.py index fb1938d..2dfe87e 100755 --- a/run.py +++ b/run.py @@ -2,88 +2,158 @@ import argparse import os import subprocess +import sys +from glob import glob +from pathlib import Path + import nibabel import numpy -from glob import glob -__version__ = open(os.path.join(os.path.dirname(os.path.realpath(__file__)), - 'version')).read() +__version__ = open(Path(__file__).parent / "version").read() -def run(command, env={}): + +def run(command, env=None): + if env is None: + env = {} merged_env = os.environ merged_env.update(env) - process = subprocess.Popen(command, stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, shell=True, - env=merged_env) + process = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + shell=True, + env=merged_env, + ) while True: line = process.stdout.readline() - line = str(line, 'utf-8')[:-1] + line = str(line, "utf-8")[:-1] print(line) - if line == '' and process.poll() != None: + if line == "" and process.poll() != None: break if process.returncode != 0: - raise Exception("Non zero return code: %d"%process.returncode) - -parser = argparse.ArgumentParser(description='Example BIDS App entrypoint script.') -parser.add_argument('bids_dir', help='The directory with the input dataset ' - 'formatted according to the BIDS standard.') -parser.add_argument('output_dir', help='The directory where the output files ' - 'should be stored. If you are running group level analysis ' - 'this folder should be prepopulated with the results of the' - 'participant level analysis.') -parser.add_argument('analysis_level', help='Level of the analysis that will be performed. ' - 'Multiple participant level analyses can be run independently ' - '(in parallel) using the same output_dir.', - choices=['participant', 'group']) -parser.add_argument('--participant_label', help='The label(s) of the participant(s) that should be analyzed. The label ' - 'corresponds to sub- from the BIDS spec ' - '(so it does not include "sub-"). If this parameter is not ' - 'provided all subjects should be analyzed. Multiple ' - 'participants can be specified with a space separated list.', - nargs="+") -parser.add_argument('--skip_bids_validator', help='Whether or not to perform BIDS dataset validation', - action='store_true') -parser.add_argument( - '-v', - '--version', - action='version', - version=f'BIDS-App example version {__version__}', -) - - -args = parser.parse_args() - -if not args.skip_bids_validator: - run(f'bids-validator {args.bids_dir}') - -subjects_to_analyze = [] -# only for a subset of subjects -if args.participant_label: - subjects_to_analyze = args.participant_label -# for all subjects -else: - subject_dirs = glob(os.path.join(args.bids_dir, "sub-*")) - subjects_to_analyze = [subject_dir.split("-")[-1] for subject_dir in subject_dirs] - -# running participant level -if args.analysis_level == "participant": - - # find all T1s and skullstrip them - for subject_label in subjects_to_analyze: - for T1_file in (glob(os.path.join(args.bids_dir, f"sub-{subject_label}", "anat", "*_T1w.nii*")) - + glob(os.path.join(args.bids_dir, f"sub-{subject_label}", "ses-*", "anat", "*_T1w.nii*"))): - out_file = os.path.split(T1_file)[-1].replace("_T1w.", "_brain.") - cmd = f"bet {T1_file} {os.path.join(args.output_dir, out_file)}" - print(cmd) - run(cmd) - -elif args.analysis_level == "group": - brain_sizes = [] - for subject_label in subjects_to_analyze: - for brain_file in glob(os.path.join(args.output_dir, f"sub-{subject_label}*.nii*")): - data = nibabel.load(brain_file).get_fdata() - # calcualte average mask size in voxels - brain_sizes.append((data != 0).sum()) - - with open(os.path.join(args.output_dir, "avg_brain_size.txt"), 'w') as fp: - fp.write("Average brain size is %g voxels"%numpy.array(brain_sizes).mean()) + raise Exception("Non zero return code: %d" % process.returncode) + + +def return_parser(): + parser = argparse.ArgumentParser( + description="Example BIDS App entrypoint script." + ) + parser.add_argument( + "bids_dir", + help="The directory with the input dataset formatted according to the BIDS standard.", + ) + parser.add_argument( + "output_dir", + help=""" +The directory where the output files should be stored. +If you are running group level analysis this folder should be prepopulated +with the results of the participant level analysis.""", + ) + parser.add_argument( + "analysis_level", + help=""" +Level of the analysis that will be performed. +Multiple participant level analyses can be run independently +in parallel) using the same output_dir.""", + choices=["participant", "group"], + ) + parser.add_argument( + "--participant_label", + help=""" +The label(s) of the participant(s) that should be analyzed. +The label corresponds to sub- from the BIDS spec +(so it does not include "sub-"). If this parameter is not provided all subjects should be analyzed. +Multiple participants can be specified with a space separated list.""", + nargs="+", + ) + parser.add_argument( + "--skip_bids_validator", + help="Whether or not to perform BIDS dataset validation.", + action="store_true", + ) + parser.add_argument( + "-v", + "--version", + action="version", + version=f"BIDS-App example version {__version__}", + ) + return parser + + +def main(argv=sys.argv): + + parser = return_parser() + args, unknowns = parser.parse_known_args(argv[1:]) + + if unknowns: + print(f"The following arguments are unknown: {unknowns}") + exit(64) + + if not args.skip_bids_validator: + run(f"bids-validator {args.bids_dir}") + + subjects_to_analyze = [] + # only for a subset of subjects + if args.participant_label: + subjects_to_analyze = args.participant_label + # for all subjects + else: + subject_dirs = glob(os.path.join(args.bids_dir, "sub-*")) + subjects_to_analyze = [ + subject_dir.split("-")[-1] for subject_dir in subject_dirs + ] + + # running participant level + if args.analysis_level == "participant": + + # find all T1s and skullstrip them + for subject_label in subjects_to_analyze: + for T1_file in glob( + os.path.join( + args.bids_dir, f"sub-{subject_label}", "anat", "*_T1w.nii*" + ) + ) + glob( + os.path.join( + args.bids_dir, + f"sub-{subject_label}", + "ses-*", + "anat", + "*_T1w.nii*", + ) + ): + out_file = os.path.split(T1_file)[-1].replace( + "_T1w.", "_brain." + ) + cmd = ( + f"bet {T1_file} {os.path.join(args.output_dir, out_file)}" + ) + print(cmd) + run(cmd) + + exit(0) + + elif args.analysis_level == "group": + brain_sizes = [] + for subject_label in subjects_to_analyze: + for brain_file in glob( + os.path.join(args.output_dir, f"sub-{subject_label}*.nii*") + ): + data = nibabel.load(brain_file).get_fdata() + # calculate average mask size in voxels + brain_sizes.append((data != 0).sum()) + + with open( + os.path.join(args.output_dir, "avg_brain_size.txt"), "w" + ) as fp: + fp.write( + f"Average brain size is {numpy.array(brain_sizes).mean()} voxels" + ) + print( + f"Results were saved in {Path(args.output_dir) / 'avg_brain_size.txt'}" + ) + + exit(0) + + +if __name__ == "__main__": + main() diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..017e43b --- /dev/null +++ b/tox.ini @@ -0,0 +1,33 @@ +; See https://tox.wiki/en +[tox] +requires = + tox>=4 +; run lint by default when just calling "tox" +env_list = lint + +; ENVIRONMENTS +; ------------ +[style] +description = common environment for style checkers (rely on pre-commit hooks) +skip_install = true +deps = + pre-commit + +; COMMANDS +; -------- +[testenv:lint] +description = install pre-commit hooks and run all linters and formatters +skip_install = true +deps = + {[style]deps} +commands = + pre-commit install + pre-commit run --all-files --show-diff-on-failure {posargs:} + +[testenv:update_dependencies] +description = update requirements.txt +skip_install = true +deps = + pip-tools +commands = + pip-compile --strip-extras requirements.in {posargs:}