diff --git a/.circleci/config.yml b/.circleci/config.yml index 6e0e5b7802..4faeb95677 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,7 +1,7 @@ setup: true version: 2.1 orbs: - continuation: circleci/continuation@0.3.1 + continuation: circleci/continuation@1.1.0 parameters: run_tests: diff --git a/.circleci/main.yml b/.circleci/main.yml index a13300a78d..9e5f3b2a01 100644 --- a/.circleci/main.yml +++ b/.circleci/main.yml @@ -2,6 +2,9 @@ version: 2.1 # Singularity started failing to set up on Circle circa May 2023, so those tests are currently disabled +orbs: + codecov: codecov/codecov@5.3.0 + parameters: branch: type: string @@ -36,7 +39,8 @@ commands: name: "Combining and reporting coverage" command: | coverage combine - coverage html --ignore-errors + coverage xml -o coverage.xml # Generate XML report + - codecov/upload configure-git-user: steps: - add_ssh_keys: @@ -45,10 +49,6 @@ commands: - run: name: "Configuring git user" command: | - sudo apt-key adv --recv-keys --keyserver keyserver.ubuntu.com 78BD65473CB3BD13 - curl -L https://packagecloud.io/circleci/trusty/gpgkey | sudo apt-key add - - sudo apt-get update - sudo apt-get install git -y git config --global user.email "CMI_CPAC_Support@childmind.org" git config --global user.name "Theodore (machine user) @ CircleCI" create-docker-test-container: @@ -64,11 +64,6 @@ commands: mkdir -p ~/project/test-results docker pull ${DOCKER_TAG} docker run -v /etc/passwd:/etc/passwd --user=$(id -u):c-pac -dit -P -e COVERAGE_FILE=<< parameters.coverage-file >> -v /home/circleci/project/test-results:/code/test-results -v /home/circleci:/home/circleci -v /home/circleci/project/CPAC/resources/configs/test_configs:/test_configs -v $PWD:/code -v $PWD/dev/circleci_data:$PWD/dev/circleci_data --workdir=/home/circleci/project --entrypoint=/bin/bash --name docker_test ${DOCKER_TAG} - get-sample-bids-data: - steps: - - run: - name: Getting Sample BIDS Data - command: git clone https://github.com/bids-standard/bids-examples.git get-singularity: parameters: version: @@ -124,7 +119,7 @@ commands: coverage-file: .coverage.docker${VARIANT} - run: name: Running pytest in Docker image - command: docker exec --user $(id -u) docker_test /bin/bash /code/dev/circleci_data/test_in_image.sh + command: docker exec -e OSF_DATA --user $(id -u) docker_test /bin/bash /code/dev/circleci_data/test_in_image.sh set-python-version: steps: - restore_cache: @@ -177,7 +172,7 @@ commands: jobs: combine-coverage: machine: - image: ubuntu-2004:2023.04.2 + image: ubuntu-2404:2024.11.1 steps: - checkout - restore_cache: @@ -194,8 +189,6 @@ jobs: # key: coverage-singularity-lite-{{ .Revision }} - set-python-version - combine-coverage - - store_artifacts: - path: htmlcov push-branch-to-docker-hub: parameters: variant: @@ -231,7 +224,6 @@ jobs: - set-up-variant: variant: "<< parameters.variant >>" - set-python-version - - get-sample-bids-data - run-pytest-docker - store_test_results: path: test-results diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000000..44836c40ce --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,21 @@ +codecov: + branch: main + +comment: + layout: "diff, files" + behavior: default + require_changes: false + require_base: false + require_head: true + hide_project_coverage: false + +coverage: + precision: 1 + range: "50..90" + round: nearest + status: + project: + default: # default is the status check's name, not default settings + only_pulls: false + target: auto + threshold: "5" diff --git a/.dockerignore b/.dockerignore index 4ea69480d5..ef5287d138 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,5 +4,6 @@ cpac_runs .env* .git .github +!.github/CODEOWNERS !.github/scripts *.tar.gz diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..81b0a84a6e --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,30 @@ +# Copyright (C) 2025 C-PAC Developers + +# This file is part of C-PAC. + +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. + +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . + +# Global maintenance +* @FCP-INDI/Maintenance + +# DevOps +/pyproject.toml @FCP-INDI/DevOps +/requirements.txt @FCP-INDI/DevOps +/setup.py @FCP-INDI/DevOps +/dev @FCP-INDI/DevOps +/scripts @FCP-INDI/DevOps +/.* @FCP-INDI/DevOps +/.circleci @FCP-INDI/DevOps +/.github @FCP-INDI/DevOps +**/*Dockerfile @FCP-INDI/DevOps diff --git a/.github/Dockerfiles/AFNI.23.3.09-jammy.Dockerfile b/.github/Dockerfiles/AFNI.23.3.09-jammy.Dockerfile index 654146ec78..ebd664ce59 100644 --- a/.github/Dockerfiles/AFNI.23.3.09-jammy.Dockerfile +++ b/.github/Dockerfiles/AFNI.23.3.09-jammy.Dockerfile @@ -14,136 +14,137 @@ # You should have received a copy of the GNU Lesser General Public # License along with C-PAC. If not, see . -FROM ghcr.io/fcp-indi/c-pac/fsl:6.0.6.5-jammy as FSL -FROM ghcr.io/fcp-indi/c-pac/ubuntu:jammy-non-free as AFNI +FROM ghcr.io/fcp-indi/c-pac/fsl:6.0.6.5-jammy AS fsl +FROM ghcr.io/fcp-indi/c-pac/ubuntu:jammy-non-free AS afni USER root ENV AFNI_VERSION="23.3.09" # To use the same Python environment to share common libraries -COPY --from=FSL /usr/share/fsl/6.0 /usr/share/fsl/6.0 +COPY --from=fsl /usr/share/fsl/6.0 /usr/share/fsl/6.0 ENV FSLDIR=/usr/share/fsl/6.0 \ - PATH=/usr/share/fsl/6.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ - LD_LIBRARY_PATH=/usr/share/fsl/6.0/lib:$LD_LIBRARY_PATH + PATH=/usr/share/fsl/6.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + LD_LIBRARY_PATH=/usr/share/fsl/6.0/lib:$LD_LIBRARY_PATH # install AFNI COPY dev/docker_data/required_afni_pkgs.txt /opt/required_afni_pkgs.txt COPY dev/docker_data/checksum/AFNI.${AFNI_VERSION}.sha384 /tmp/AFNI.${AFNI_VERSION}.sha384 ENV PATH=/opt/afni:$PATH RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - apt-transport-https \ - bc \ - bzip2 \ - cmake \ - curl \ - dh-autoreconf \ - eog \ - evince \ - firefox \ - gedit \ - git \ - gnome-terminal \ - gnome-tweaks \ - gnupg \ - gsl-bin \ - libcanberra-gtk-module \ - libcurl4-openssl-dev \ - libexpat1-dev \ - libgdal-dev \ - libgfortran-11-dev \ - libgiftiio-dev \ - libgl1-mesa-dri \ - libglib2.0-dev \ - libglu1-mesa \ - libglu1-mesa-dev \ - libglw1-mesa \ - libglw1-mesa-dev \ - libgomp1 \ - libgsl-dev \ - libjpeg-progs \ - libjpeg62 \ - libmotif-dev \ - libnode-dev \ - libopenblas-dev \ - libssl-dev \ - libtool \ - libudunits2-dev \ - libx11-dev \ - libxext-dev \ - libxft-dev \ - libxft2 \ - libxi-dev \ - libxm4 \ - libxml2 \ - libxml2-dev \ - libxmu-dev \ - libxmu-headers \ - libxpm-dev \ - libxslt1-dev \ - m4 \ - mesa-common-dev \ - mesa-utils \ - nautilus \ - netpbm \ - ninja-build \ - openssh-client \ - pkg-config \ - r-base-dev \ - rsync \ - software-properties-common \ - tcsh \ - unzip \ - vim \ - wget \ - x11proto-xext-dev \ - xauth \ - xfonts-100dpi \ - xfonts-base \ - xterm \ - xutils-dev \ - xvfb \ - zlib1g-dev \ - && curl -LOJ https://github.com/afni/afni/archive/AFNI_${AFNI_VERSION}.tar.gz \ - && sha384sum --check /tmp/AFNI.${AFNI_VERSION}.sha384 \ - && ln -svf /usr/lib/x86_64-linux-gnu/libgsl.so.27 /usr/lib/x86_64-linux-gnu/libgsl.so.19 \ - && ln -svf /usr/lib/x86_64-linux-gnu/libgsl.so.27 /usr/lib/x86_64-linux-gnu/libgsl.so.0 \ - && mkdir /opt/afni \ - && tar -xvf afni-AFNI_${AFNI_VERSION}.tar.gz -C /opt/afni --strip-components 1 \ - && rm -rf afni-AFNI_${AFNI_VERSION}.tar.gz \ - # Fix GLwDrawA per https://github.com/afni/afni/blob/AFNI_23.1.10/src/other_builds/OS_notes.linux_fedora_25_64.txt - && cd /usr/include/GL \ - && mv GLwDrawA.h GLwDrawA.h.orig \ - && sed 's/GLAPI WidgetClass/extern GLAPI WidgetClass/' GLwDrawA.h.orig > GLwDrawA.h \ - && cd /opt/afni/src \ - && sed '/^INSTALLDIR =/c INSTALLDIR = /opt/afni' other_builds/Makefile.linux_ubuntu_22_64 > Makefile \ - && make vastness && make cleanest \ - && cd /opt/afni \ - && VERSION_STRING=$(afni --version) \ - && VERSION_NAME=$(echo $VERSION_STRING | awk -F"'" '{print $2}') \ - # filter down to required packages - && ls > full_ls \ - && sed 's/linux_openmp_64\///g' /opt/required_afni_pkgs.txt | sort > required_ls \ - && comm -2 -3 full_ls required_ls | xargs rm -rf full_ls required_ls \ - # get rid of stuff we just needed for building - && apt-get remove -y \ - bzip2 \ - cmake \ - curl \ - dh-autoreconf \ - evince \ - firefox \ - gedit \ - git \ - gnome-terminal \ - gnome-tweaks \ - libglw1-mesa-dev \ - m4 \ - ninja-build \ - openssh-client \ - unzip \ - wget \ - xterm \ - && ldconfig \ - && rm -rf /opt/afni/src + && apt-get install -y --no-install-recommends \ + apt-transport-https \ + bc \ + bzip2 \ + cmake \ + curl \ + dh-autoreconf \ + eog \ + evince \ + firefox \ + gedit \ + git \ + gnome-terminal \ + gnome-tweaks \ + gnupg \ + gsl-bin \ + libcanberra-gtk-module \ + libcurl4-openssl-dev \ + libexpat1-dev \ + libgdal-dev \ + libgfortran-11-dev \ + libgiftiio-dev \ + libgl1-mesa-dri \ + libglib2.0-dev \ + libglu1-mesa \ + libglu1-mesa-dev \ + libglw1-mesa \ + libglw1-mesa-dev \ + libgomp1 \ + libgsl-dev \ + libjpeg-progs \ + libjpeg62 \ + libmotif-dev \ + libnode-dev \ + libopenblas-dev \ + libssl-dev \ + libtool \ + libudunits2-dev \ + libx11-dev \ + libxext-dev \ + libxft-dev \ + libxft2 \ + libxi-dev \ + libxm4 \ + libxml2 \ + libxml2-dev \ + libxmu-dev \ + libxmu-headers \ + libxpm-dev \ + libxslt1-dev \ + m4 \ + mesa-common-dev \ + mesa-utils \ + nautilus \ + netpbm \ + ninja-build \ + openssh-client \ + pkg-config \ + r-base-dev \ + rsync \ + software-properties-common \ + tcsh \ + unzip \ + vim \ + wget \ + x11proto-xext-dev \ + xauth \ + xfonts-100dpi \ + xfonts-base \ + xterm \ + xutils-dev \ + xvfb \ + zlib1g-dev \ + && curl -LOJ https://github.com/afni/afni/archive/AFNI_${AFNI_VERSION}.tar.gz \ + #&& sha384sum --check /tmp/AFNI.${AFNI_VERSION}.sha384 \ + && ln -svf /usr/lib/x86_64-linux-gnu/libgsl.so.27 /usr/lib/x86_64-linux-gnu/libgsl.so.19 \ + && ln -svf /usr/lib/x86_64-linux-gnu/libgsl.so.27 /usr/lib/x86_64-linux-gnu/libgsl.so.0 \ + && mkdir /opt/afni \ + && tar -xvf afni-AFNI_${AFNI_VERSION}.tar.gz -C /opt/afni --strip-components 1 \ + && rm -rf afni-AFNI_${AFNI_VERSION}.tar.gz \ + # Fix GLwDrawA per https://github.com/afni/afni/blob/AFNI_23.1.10/src/other_builds/OS_notes.linux_fedora_25_64.txt + && cd /usr/include/GL \ + && mv GLwDrawA.h GLwDrawA.h.orig \ + && sed 's/GLAPI WidgetClass/extern GLAPI WidgetClass/' GLwDrawA.h.orig > GLwDrawA.h \ + && cd /opt/afni/src \ + && sed '/^INSTALLDIR =/c INSTALLDIR = /opt/afni' other_builds/Makefile.linux_ubuntu_22_64 > Makefile \ + && make vastness && make cleanest \ + && cd /opt/afni \ + && VERSION_STRING=$(afni --version) \ + && VERSION_NAME=$(echo $VERSION_STRING | awk -F"'" '{print $2}') \ + # filter down to required packages + && ls > full_ls \ + && sed 's/linux_openmp_64\///g' /opt/required_afni_pkgs.txt | sort > required_ls \ + && grep -qxF '3dWarp' full_ls || echo '3dWarp' >> required_ls \ + && comm -2 -3 full_ls required_ls | xargs rm -rf \ + # get rid of stuff we just needed for building + && apt-get remove -y \ + bzip2 \ + cmake \ + curl \ + dh-autoreconf \ + evince \ + firefox \ + gedit \ + git \ + gnome-terminal \ + gnome-tweaks \ + libglw1-mesa-dev \ + m4 \ + ninja-build \ + openssh-client \ + unzip \ + wget \ + xterm \ + && ldconfig \ + && rm -rf /opt/afni/src ENTRYPOINT ["/bin/bash"] @@ -151,14 +152,14 @@ ENTRYPOINT ["/bin/bash"] RUN ldconfig RUN apt-get clean \ - && apt-get autoremove -y \ - && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /root/.cache/* + && apt-get autoremove -y \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /root/.cache/* FROM scratch -LABEL org.opencontainers.image.description "NOT INTENDED FOR USE OTHER THAN AS A STAGE IMAGE IN A MULTI-STAGE BUILD \ -AFNI ${AFNI_VERSION} (${VERSION_NAME}) stage" -LABEL org.opencontainers.image.source https://github.com/FCP-INDI/C-PAC -COPY --from=AFNI /lib/x86_64-linux-gnu/ld* /lib/x86_64-linux-gnu/ -COPY --from=AFNI /lib/x86_64-linux-gnu/lib*so* /lib/x86_64-linux-gnu/ -COPY --from=AFNI /lib64/ld* /lib64/ -COPY --from=AFNI /opt/afni/ /opt/afni/ +LABEL org.opencontainers.image.description="NOT INTENDED FOR USE OTHER THAN AS A STAGE IMAGE IN A MULTI-STAGE BUILD \ + AFNI ${AFNI_VERSION} (${VERSION_NAME}) stage" +LABEL org.opencontainers.image.source=https://github.com/FCP-INDI/C-PAC +COPY --from=afni /lib/x86_64-linux-gnu/ld* /lib/x86_64-linux-gnu/ +COPY --from=afni /lib/x86_64-linux-gnu/lib*so* /lib/x86_64-linux-gnu/ +COPY --from=afni /lib64/ld* /lib64/ +COPY --from=afni /opt/afni/ /opt/afni/ diff --git a/.github/Dockerfiles/ANTs.2.4.3-jammy.Dockerfile b/.github/Dockerfiles/ANTs.2.4.3-jammy.Dockerfile index 03dd017b84..67cb8fdfad 100644 --- a/.github/Dockerfiles/ANTs.2.4.3-jammy.Dockerfile +++ b/.github/Dockerfiles/ANTs.2.4.3-jammy.Dockerfile @@ -30,8 +30,8 @@ RUN curl -sL https://github.com/ANTsX/ANTs/releases/download/v2.4.3/ants-2.4.3-u # Only keep what we need FROM scratch -LABEL org.opencontainers.image.description "NOT INTENDED FOR USE OTHER THAN AS A STAGE IMAGE IN A MULTI-STAGE BUILD \ +LABEL org.opencontainers.image.description="NOT INTENDED FOR USE OTHER THAN AS A STAGE IMAGE IN A MULTI-STAGE BUILD \ ANTs 2.4.3 stage" -LABEL org.opencontainers.image.source https://github.com/FCP-INDI/C-PAC +LABEL org.opencontainers.image.source=https://github.com/FCP-INDI/C-PAC COPY --from=ANTs /usr/lib/ants/ /usr/lib/ants/ COPY --from=ANTs /ants_template/ /ants_template/ diff --git a/.github/Dockerfiles/C-PAC.develop-jammy.Dockerfile b/.github/Dockerfiles/C-PAC.develop-jammy.Dockerfile index 838d8dcc4b..e41bd6fc73 100644 --- a/.github/Dockerfiles/C-PAC.develop-jammy.Dockerfile +++ b/.github/Dockerfiles/C-PAC.develop-jammy.Dockerfile @@ -15,14 +15,14 @@ # You should have received a copy of the GNU Lesser General Public # License along with C-PAC. If not, see . FROM ghcr.io/fcp-indi/c-pac/stage-base:standard-v1.8.8.dev1 -LABEL org.opencontainers.image.description "Full C-PAC image" -LABEL org.opencontainers.image.source https://github.com/FCP-INDI/C-PAC +LABEL org.opencontainers.image.description="Full C-PAC image" +LABEL org.opencontainers.image.source=https://github.com/FCP-INDI/C-PAC USER root # install C-PAC COPY dev/circleci_data/pipe-test_ci.yml /cpac_resources/pipe-test_ci.yml COPY . /code -RUN pip cache purge && pip install -e "/code[graphviz]" +RUN pip cache purge && pip install backports.tarfile && pip install -e "/code[graphviz]" # set up runscript COPY dev/docker_data /code/docker_data RUN rm -Rf /code/docker_data/checksum && \ @@ -45,7 +45,8 @@ RUN rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /root/.cache/* \ && chmod 777 $(ls / | grep -v sys | grep -v proc) ENV PYTHONUSERBASE=/home/c-pac_user/.local ENV PATH=$PATH:/home/c-pac_user/.local/bin \ - PYTHONPATH=$PYTHONPATH:$PYTHONUSERBASE/lib/python3.10/site-packages + PYTHONPATH=$PYTHONPATH:$PYTHONUSERBASE/lib/python3.10/site-packages \ + _SHELL=/bin/bash # set user WORKDIR /home/c-pac_user diff --git a/.github/Dockerfiles/C-PAC.develop-lite-jammy.Dockerfile b/.github/Dockerfiles/C-PAC.develop-lite-jammy.Dockerfile index b58801b519..6f350c4f18 100644 --- a/.github/Dockerfiles/C-PAC.develop-lite-jammy.Dockerfile +++ b/.github/Dockerfiles/C-PAC.develop-lite-jammy.Dockerfile @@ -15,15 +15,15 @@ # You should have received a copy of the GNU Lesser General Public # License along with C-PAC. If not, see . FROM ghcr.io/fcp-indi/c-pac/stage-base:lite-v1.8.8.dev1 -LABEL org.opencontainers.image.description "Full C-PAC image without FreeSurfer" -LABEL org.opencontainers.image.source https://github.com/FCP-INDI/C-PAC +LABEL org.opencontainers.image.description="Full C-PAC image without FreeSurfer" +LABEL org.opencontainers.image.source=https://github.com/FCP-INDI/C-PAC USER root # install C-PAC COPY dev/circleci_data/pipe-test_ci.yml /cpac_resources/pipe-test_ci.yml COPY . /code COPY --from=ghcr.io/fcp-indi/c-pac_templates:latest /cpac_templates /cpac_templates -RUN pip cache purge && pip install -e "/code[graphviz]" +RUN pip cache purge && pip install backports.tarfile && pip install -e "/code[graphviz]" # set up runscript COPY dev/docker_data /code/docker_data RUN rm -Rf /code/docker_data/checksum && \ @@ -46,7 +46,8 @@ RUN rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /root/.cache/* \ && chmod 777 $(ls / | grep -v sys | grep -v proc) ENV PYTHONUSERBASE=/home/c-pac_user/.local ENV PATH=$PATH:/home/c-pac_user/.local/bin \ - PYTHONPATH=$PYTHONPATH:$PYTHONUSERBASE/lib/python3.10/site-packages + PYTHONPATH=$PYTHONPATH:$PYTHONUSERBASE/lib/python3.10/site-packages \ + _SHELL=/bin/bash # set user WORKDIR /home/c-pac_user diff --git a/.github/Dockerfiles/FSL.6.0.6.5-jammy.Dockerfile b/.github/Dockerfiles/FSL.6.0.6.5-jammy.Dockerfile index e4ff0f9b25..112b0feda1 100644 --- a/.github/Dockerfiles/FSL.6.0.6.5-jammy.Dockerfile +++ b/.github/Dockerfiles/FSL.6.0.6.5-jammy.Dockerfile @@ -101,9 +101,9 @@ ENTRYPOINT ["/bin/bash"] # # Only keep what we need FROM scratch -LABEL org.opencontainers.image.description "NOT INTENDED FOR USE OTHER THAN AS A STAGE IMAGE IN A MULTI-STAGE BUILD \ +LABEL org.opencontainers.image.description="NOT INTENDED FOR USE OTHER THAN AS A STAGE IMAGE IN A MULTI-STAGE BUILD \ FSL 6.0.6.5 stage" -LABEL org.opencontainers.image.source https://github.com/FCP-INDI/C-PAC +LABEL org.opencontainers.image.source=https://github.com/FCP-INDI/C-PAC COPY --from=FSL /lib/x86_64-linux-gnu /lib/x86_64-linux-gnu COPY --from=FSL /usr/lib/x86_64-linux-gnu /usr/lib/x86_64-linux-gnu COPY --from=FSL /usr/bin /usr/bin diff --git a/.github/Dockerfiles/FSL.data.Dockerfile b/.github/Dockerfiles/FSL.data.Dockerfile index c7e0b593e4..816b5e1547 100644 --- a/.github/Dockerfiles/FSL.data.Dockerfile +++ b/.github/Dockerfiles/FSL.data.Dockerfile @@ -18,9 +18,9 @@ RUN mkdir -p /fsl_data/atlases/HarvardOxford fsl_data/standard/tissuepriors \ && chmod -R ugo+r /fsl_data/atlases FROM scratch -LABEL org.opencontainers.image.description "NOT INTENDED FOR USE OTHER THAN AS A STAGE IMAGE IN A MULTI-STAGE BUILD \ +LABEL org.opencontainers.image.description="NOT INTENDED FOR USE OTHER THAN AS A STAGE IMAGE IN A MULTI-STAGE BUILD \ FSL data" -LABEL org.opencontainers.image.source https://github.com/FCP-INDI/C-PAC +LABEL org.opencontainers.image.source=https://github.com/FCP-INDI/C-PAC COPY --from=FSL /fsl_data/standard fsl_data/standard COPY --from=FSL /fsl_data/atlases fsl_data/atlases diff --git a/.github/Dockerfiles/FreeSurfer.6.0.0-min.neurodocker-jammy.Dockerfile b/.github/Dockerfiles/FreeSurfer.6.0.0-min.neurodocker-jammy.Dockerfile index 811d20f617..ae6eac7548 100644 --- a/.github/Dockerfiles/FreeSurfer.6.0.0-min.neurodocker-jammy.Dockerfile +++ b/.github/Dockerfiles/FreeSurfer.6.0.0-min.neurodocker-jammy.Dockerfile @@ -32,7 +32,7 @@ RUN apt-get clean && \ rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* FROM scratch -LABEL org.opencontainers.image.description "NOT INTENDED FOR USE OTHER THAN AS A STAGE IMAGE IN A MULTI-STAGE BUILD \ +LABEL org.opencontainers.image.description="NOT INTENDED FOR USE OTHER THAN AS A STAGE IMAGE IN A MULTI-STAGE BUILD \ FreeSurfer 6.0.0-min stage" -LABEL org.opencontainers.image.source https://github.com/FCP-INDI/C-PAC +LABEL org.opencontainers.image.source=https://github.com/FCP-INDI/C-PAC COPY --from=FreeSurfer6 /usr/lib/freesurfer/ /usr/lib/freesurfer/ diff --git a/.github/Dockerfiles/ICA-AROMA.0.4.4-beta-jammy.Dockerfile b/.github/Dockerfiles/ICA-AROMA.0.4.4-beta-jammy.Dockerfile index 2759c529eb..cc188c9aa2 100644 --- a/.github/Dockerfiles/ICA-AROMA.0.4.4-beta-jammy.Dockerfile +++ b/.github/Dockerfiles/ICA-AROMA.0.4.4-beta-jammy.Dockerfile @@ -24,6 +24,6 @@ USER c-pac_user # Only keep what we need FROM scratch -LABEL org.opencontainers.image.description "NOT INTENDED FOR USE OTHER THAN AS A STAGE IMAGE IN A MULTI-STAGE BUILD \ +LABEL org.opencontainers.image.description="NOT INTENDED FOR USE OTHER THAN AS A STAGE IMAGE IN A MULTI-STAGE BUILD \ ICA-AROMA 0.4.4-beta stage" COPY --from=ICA-AROMA /opt/ICA-AROMA/ /opt/ICA-AROMA/ diff --git a/.github/Dockerfiles/Ubuntu.jammy-non-free.Dockerfile b/.github/Dockerfiles/Ubuntu.jammy-non-free.Dockerfile index 3017126770..5b8a653751 100644 --- a/.github/Dockerfiles/Ubuntu.jammy-non-free.Dockerfile +++ b/.github/Dockerfiles/Ubuntu.jammy-non-free.Dockerfile @@ -26,9 +26,9 @@ RUN apt-get update \ # use neurodebian runtime as parent image FROM neurodebian:jammy-non-free -LABEL org.opencontainers.image.description "NOT INTENDED FOR USE OTHER THAN AS A STAGE IMAGE IN A MULTI-STAGE BUILD \ +LABEL org.opencontainers.image.description="NOT INTENDED FOR USE OTHER THAN AS A STAGE IMAGE IN A MULTI-STAGE BUILD \ Ubuntu Jammy base image" -LABEL org.opencontainers.image.source https://github.com/FCP-INDI/C-PAC +LABEL org.opencontainers.image.source=https://github.com/FCP-INDI/C-PAC ARG BIDS_VALIDATOR_VERSION=1.14.6 \ DEBIAN_FRONTEND=noninteractive ENV TZ=America/New_York \ diff --git a/.github/Dockerfiles/base-lite.Dockerfile b/.github/Dockerfiles/base-lite.Dockerfile index 25c494942f..5c156e2c77 100644 --- a/.github/Dockerfiles/base-lite.Dockerfile +++ b/.github/Dockerfiles/base-lite.Dockerfile @@ -1,4 +1,4 @@ -# Copyright (C) 2023 C-PAC Developers +# Copyright (C) 2023-2025 C-PAC Developers # This file is part of C-PAC. @@ -14,17 +14,17 @@ # You should have received a copy of the GNU Lesser General Public # License along with C-PAC. If not, see . -FROM ghcr.io/fcp-indi/c-pac/afni:23.3.09-jammy as AFNI -FROM ghcr.io/fcp-indi/c-pac/ants:2.4.3-jammy as ANTs -FROM ghcr.io/fcp-indi/c-pac/c3d:1.0.0-jammy as c3d -FROM ghcr.io/fcp-indi/c-pac/connectome-workbench:1.5.0.neurodebian-jammy as connectome-workbench -FROM ghcr.io/fcp-indi/c-pac/fsl:6.0.6.5-jammy as FSL -FROM ghcr.io/fcp-indi/c-pac/ica-aroma:0.4.4-beta-jammy as ICA-AROMA +FROM ghcr.io/fcp-indi/c-pac/afni:23.3.09-jammy AS afni +FROM ghcr.io/fcp-indi/c-pac/ants:2.4.3-jammy AS ants +FROM ghcr.io/fcp-indi/c-pac/c3d:1.0.0-jammy AS c3d +FROM ghcr.io/fcp-indi/c-pac/connectome-workbench:1.5.0.neurodebian-jammy AS connectome-workbench +FROM ghcr.io/fcp-indi/c-pac/fsl:6.0.6.5-jammy AS fsl +FROM ghcr.io/fcp-indi/c-pac/ica-aroma:0.4.4-beta-jammy AS ica-aroma FROM ghcr.io/fcp-indi/c-pac/ubuntu:jammy-non-free -LABEL org.opencontainers.image.description "NOT INTENDED FOR USE OTHER THAN AS A STAGE IMAGE IN A MULTI-STAGE BUILD \ -Standard software dependencies for C-PAC standard and lite images" -LABEL org.opencontainers.image.source https://github.com/FCP-INDI/C-PAC +LABEL org.opencontainers.image.description="NOT INTENDED FOR USE OTHER THAN AS A STAGE IMAGE IN A MULTI-STAGE BUILD \ + Standard software dependencies for C-PAC standard and lite images" +LABEL org.opencontainers.image.source=https://github.com/FCP-INDI/C-PAC USER root # Installing connectome-workbench @@ -47,19 +47,21 @@ ENV FSLTCLSH=$FSLDIR/bin/fsltclsh \ PATH=${FSLDIR}/bin:$PATH \ TZ=America/New_York \ USER=c-pac_user -COPY --from=FSL /lib/x86_64-linux-gnu /lib/x86_64-linux-gnu -COPY --from=FSL /usr/lib/x86_64-linux-gnu /usr/lib/x86_64-linux-gnu -COPY --from=FSL /usr/bin /usr/bin -COPY --from=FSL /usr/local/bin /usr/local/bin -COPY --from=FSL /usr/share/fsl /usr/share/fsl +COPY --from=fsl /lib/x86_64-linux-gnu /lib/x86_64-linux-gnu +COPY --from=fsl /usr/lib/x86_64-linux-gnu /usr/lib/x86_64-linux-gnu +COPY --from=fsl /usr/bin /usr/bin +COPY --from=fsl /usr/local/bin /usr/local/bin +COPY --from=fsl /usr/share/fsl /usr/share/fsl +RUN apt-get update \ + && apt-get install --no-install-recommends -y bc # Installing C-PAC dependencies COPY requirements.txt /opt/requirements.txt RUN mamba install git -y \ - && pip install -r /opt/requirements.txt \ - && rm -rf /opt/requirements.txt \ - && yes | mamba clean --all \ - && rm -rf /usr/share/fsl/6.0/pkgs/cache/* + && pip install -r /opt/requirements.txt \ + && rm -rf /opt/requirements.txt \ + && yes | mamba clean --all \ + && rm -rf /usr/share/fsl/6.0/pkgs/cache/* # Installing and setting up c3d COPY --from=c3d /opt/c3d/ opt/c3d/ @@ -67,10 +69,10 @@ ENV C3DPATH /opt/c3d ENV PATH $C3DPATH/bin:$PATH # Installing AFNI -COPY --from=AFNI /lib/x86_64-linux-gnu/ld* /lib/x86_64-linux-gnu/ -COPY --from=AFNI /lib/x86_64-linux-gnu/lib*so* /lib/x86_64-linux-gnu/ -COPY --from=AFNI /lib64/ld* /lib64/ -COPY --from=AFNI /opt/afni/ /opt/afni/ +COPY --from=afni /lib/x86_64-linux-gnu/ld* /lib/x86_64-linux-gnu/ +COPY --from=afni /lib/x86_64-linux-gnu/lib*so* /lib/x86_64-linux-gnu/ +COPY --from=afni /lib64/ld* /lib64/ +COPY --from=afni /opt/afni/ /opt/afni/ # set up AFNI ENV PATH=/opt/afni:$PATH @@ -79,11 +81,11 @@ ENV LANG="en_US.UTF-8" \ LC_ALL="en_US.UTF-8" \ ANTSPATH=/usr/lib/ants/bin \ PATH=/usr/lib/ants/bin:$PATH -COPY --from=ANTs /usr/lib/ants/ /usr/lib/ants/ -COPY --from=ANTs /ants_template/ /ants_template/ +COPY --from=ants /usr/lib/ants/ /usr/lib/ants/ +COPY --from=ants /ants_template/ /ants_template/ # Installing ICA-AROMA -COPY --from=ICA-AROMA /opt/ICA-AROMA/ /opt/ICA-AROMA/ +COPY --from=ica-aroma /opt/ICA-AROMA/ /opt/ICA-AROMA/ ENV PATH=/opt/ICA-AROMA:$PATH # link libraries & clean up diff --git a/.github/Dockerfiles/base-standard.Dockerfile b/.github/Dockerfiles/base-standard.Dockerfile index de7d3841e2..0eb4738375 100644 --- a/.github/Dockerfiles/base-standard.Dockerfile +++ b/.github/Dockerfiles/base-standard.Dockerfile @@ -1,4 +1,4 @@ -# Copyright (C) 2022-2023 C-PAC Developers +# Copyright (C) 2022-2025 C-PAC Developers # This file is part of C-PAC. @@ -14,17 +14,22 @@ # You should have received a copy of the GNU Lesser General Public # License along with C-PAC. If not, see . -FROM ghcr.io/fcp-indi/c-pac/freesurfer:6.0.0-min.neurodocker-jammy as FreeSurfer +FROM ghcr.io/fcp-indi/c-pac/freesurfer:6.0.0-min.neurodocker-jammy AS freesurfer FROM ghcr.io/fcp-indi/c-pac/stage-base:lite-v1.8.8.dev1 -LABEL org.opencontainers.image.description "NOT INTENDED FOR USE OTHER THAN AS A STAGE IMAGE IN A MULTI-STAGE BUILD \ +LABEL org.opencontainers.image.description="NOT INTENDED FOR USE OTHER THAN AS A STAGE IMAGE IN A MULTI-STAGE BUILD \ Standard software dependencies for C-PAC standard images" -LABEL org.opencontainers.image.source https://github.com/FCP-INDI/C-PAC +LABEL org.opencontainers.image.source=https://github.com/FCP-INDI/C-PAC USER root +# Installing ANTs +ENV LANG="en_US.UTF-8" \ + LC_ALL="en_US.UTF-8" \ + ANTSPATH=/usr/lib/ants/bin \ + PATH=/usr/lib/ants/bin:$PATH + # Installing FreeSurfer RUN apt-get update \ - && apt-get install --no-install-recommends -y bc \ && yes | mamba install tcsh \ && yes | mamba clean --all \ && cp -l `which tcsh` /bin/tcsh \ @@ -37,15 +42,16 @@ ENV PATH="$FREESURFER_HOME/bin:$PATH" \ SUBJECTS_DIR="$FREESURFER_HOME/subjects" \ MNI_DIR="$FREESURFER_HOME/mni" ENV MINC_BIN_DIR="$MNI_DIR/bin" \ - MINC_LIB_DIR="$MNI_DIR/lib" \ - PATH="$PATH:$MINC_BIN_DIR" -COPY --from=FreeSurfer /usr/lib/freesurfer/ /usr/lib/freesurfer/ + MINC_LIB_DIR="$MNI_DIR/lib" +ENV PATH="$PATH:$MINC_BIN_DIR" +COPY --from=freesurfer /usr/lib/freesurfer/ /usr/lib/freesurfer/ COPY dev/docker_data/license.txt $FREESURFER_HOME/license.txt # link libraries & clean up RUN apt-get autoremove -y \ && apt-get autoclean -y \ && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /root/.cache/* \ + && ln -s /usr/lib/x86_64-linux-gnu/libcrypt.so.1 /usr/lib/x86_64-linux-gnu/libcrypt.so.2 \ && find / -type f -print0 | sort -t/ -k2 | xargs -0 rdfind -makehardlinks true \ && rm -rf results.txt \ && ldconfig \ diff --git a/.github/Dockerfiles/c3d.1.0.0-jammy.Dockerfile b/.github/Dockerfiles/c3d.1.0.0-jammy.Dockerfile index 2c1a7f1d87..9fbcdd2386 100644 --- a/.github/Dockerfiles/c3d.1.0.0-jammy.Dockerfile +++ b/.github/Dockerfiles/c3d.1.0.0-jammy.Dockerfile @@ -36,7 +36,7 @@ RUN apt-get clean && \ rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* FROM scratch -LABEL org.opencontainers.image.description "NOT INTENDED FOR USE OTHER THAN AS A STAGE IMAGE IN A MULTI-STAGE BUILD \ +LABEL org.opencontainers.image.description="NOT INTENDED FOR USE OTHER THAN AS A STAGE IMAGE IN A MULTI-STAGE BUILD \ c3d 1.0.0 (Jammy) stage" -LABEL org.opencontainers.image.source https://github.com/FCP-INDI/C-PAC +LABEL org.opencontainers.image.source=https://github.com/FCP-INDI/C-PAC COPY --from=c3d /opt/c3d/ /opt/c3d/ diff --git a/.github/Dockerfiles/connectome-workbench.1.5.0.neurodebian-jammy.Dockerfile b/.github/Dockerfiles/connectome-workbench.1.5.0.neurodebian-jammy.Dockerfile index 2c958fd5d5..1932efbc8f 100644 --- a/.github/Dockerfiles/connectome-workbench.1.5.0.neurodebian-jammy.Dockerfile +++ b/.github/Dockerfiles/connectome-workbench.1.5.0.neurodebian-jammy.Dockerfile @@ -24,9 +24,9 @@ RUN apt-get update \ USER c-pac_user # FROM scratch -# LABEL org.opencontainers.image.description "NOT INTENDED FOR USE OTHER THAN AS A STAGE IMAGE IN A MULTI-STAGE BUILD \ +# LABEL org.opencontainers.image.description="NOT INTENDED FOR USE OTHER THAN AS A STAGE IMAGE IN A MULTI-STAGE BUILD \ # connectome-workbench 1.5.0 stage" -# LABEL org.opencontainers.image.source https://github.com/FCP-INDI/C-PAC +# LABEL org.opencontainers.image.source=https://github.com/FCP-INDI/C-PAC # COPY --from=base /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 # COPY --from=base /lib/x86_64-linux-gnu/libGL.so.1 /lib/x86_64-linux-gnu/libGL.so.1 # COPY --from=base /lib/x86_64-linux-gnu/libGLU.so.1 /lib/x86_64-linux-gnu/libGLU.so.1 diff --git a/.github/Dockerfiles/neuroparc.1.0-human-bionic.Dockerfile b/.github/Dockerfiles/neuroparc.1.0-human-bionic.Dockerfile index 2f64e0ae6f..519093d3bd 100644 --- a/.github/Dockerfiles/neuroparc.1.0-human-bionic.Dockerfile +++ b/.github/Dockerfiles/neuroparc.1.0-human-bionic.Dockerfile @@ -1,8 +1,8 @@ # using neurodebian runtime as parent image FROM neurodebian:bionic-non-free -LABEL org.opencontainers.image.description "NOT INTENDED FOR USE OTHER THAN AS A STAGE IMAGE IN A MULTI-STAGE BUILD \ +LABEL org.opencontainers.image.description="NOT INTENDED FOR USE OTHER THAN AS A STAGE IMAGE IN A MULTI-STAGE BUILD \ neuroparc v1.0-human stage" -LABEL org.opencontainers.image.source https://github.com/FCP-INDI/C-PAC +LABEL org.opencontainers.image.source=https://github.com/FCP-INDI/C-PAC ARG DEBIAN_FRONTEND=noninteractive diff --git a/.github/README/README.md b/.github/README/README.md index 158a762313..19e0a337d9 100644 --- a/.github/README/README.md +++ b/.github/README/README.md @@ -46,7 +46,7 @@ flowchart LR subgraph build_C-PAC.yml bCPAC[[C-PAC]] end - subgraph build_and_test.yml + subgraph build_and_test.yaml ubuntu[[Ubnutu]]-->stages[[stages]]-->build-base[[build-base]]-->build-base-standard[[build-base-standard]] Circle_tests[[Circle_tests]] @@ -65,7 +65,7 @@ flowchart LR smoke-tests-participant[[smoke-tests-participant]] end - on_push.yml-->build_and_test.yml + on_push.yaml-->build_and_test.yaml delete_images.yml end @@ -79,8 +79,8 @@ flowchart LR Circle_tests-->CircleCI((Run tests on Circle CI)) - on_push.yml<-->get_pr_base_shas - on_push.yml-->update_all_preconfigs + on_push.yaml<-->get_pr_base_shas + on_push.yaml-->update_all_preconfigs cpacdockerfiles<-->C-PAC @@ -94,7 +94,7 @@ flowchart LR bCPAC<-->local_ghcr stages<-->local_ghcr - push>git push]-->on_push.yml + push>git push]-->on_push.yaml smoke-tests-participant-->smoke_test_human smoke-tests-participant-->smoke_test_nhp diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000000..005e3bbfae --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,47 @@ +# Copyright (C) 2024 - 2025 C-PAC Developers + +# This file is part of C-PAC. + +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. + +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . +version: 2 +registries: + ghcr: + type: docker-registry + url: ghcr.io + username: ChildMindInstituteCNL + password: ${{ secrets.GHCR_REGISTRY_TOKEN }} +updates: + - package-ecosystem: "github-actions" + directory: / + # Check for updates once a week + schedule: + interval: weekly + groups: + all-actions: + patterns: [ "*" ] + target-branch: develop + registries: + - ghcr + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + groups: + production dependencies: + dependency-type: production + development dependencies: + dependency-type: development + target-branch: develop + registries: + - ghcr diff --git a/.github/scripts/autoversioning.sh b/.github/scripts/autoversioning.sh index f93dc3f57e..d3aa6228d6 100755 --- a/.github/scripts/autoversioning.sh +++ b/.github/scripts/autoversioning.sh @@ -1,6 +1,6 @@ -#!/bin/bash +#!/usr/bin/env bash -# Copyright (C) 2024 C-PAC Developers +# Copyright (C) 2024-2025 C-PAC Developers # This file is part of C-PAC. @@ -17,54 +17,174 @@ # You should have received a copy of the GNU Lesser General Public # License along with C-PAC. If not, see . -# Update version comment strings -function wait_for_git_lock() { - while [ -f "./.git/index.lock" ]; do - echo "Waiting for the git lock file to be removed..." - sleep 1 - done + +set -euo pipefail +trap 'echo "❌ Script failed at line $LINENO with exit code $?"' ERR + +# ------------------------------------------------------------------------- +# Helpers +# ------------------------------------------------------------------------- + +git_add_with_retry() { + local file=$1 + local attempts=0 + local max_attempts=10 + while ! git add "$file"; do + attempts=$((attempts+1)) + echo "Git add failed for $file (attempt $attempts), retrying..." + sleep 1 + if [[ $attempts -ge $max_attempts ]]; then + echo "❌ Failed to git add $file after $max_attempts attempts" + exit 1 + fi + done +} + +update_file_if_changed() { + # Run a regex replacement or copy on a file and stage it if it changed + local expr=$1 + local src=$2 + local dest=${3:-$src} + + local changed=0 + if [[ -n "$expr" ]]; then + tmp=$(mktemp) + sed -E "$expr" "$src" > "$tmp" + if ! cmp -s "$tmp" "$dest"; then + mv "$tmp" "$dest" + git_add_with_retry "$dest" + changed=1 + else + rm "$tmp" + fi + else + if [[ ! -f "$dest" ]] || ! cmp -s "$src" "$dest"; then + cp "$src" "$dest" + git_add_with_retry "$dest" + changed=1 + fi + fi + return $changed } -cd CPAC || exit 1 -VERSION=$(python -c "from info import __version__; print(('.'.join(('.'.join(__version__[::-1].split('-')[1].split('.')[1:])[::-1], __version__.split('-')[1])) if '-' in __version__ else __version__).split('+', 1)[0])") -cd .. -echo "v${VERSION}" > version -export _SED_COMMAND="s/^(# [Vv]ersion ).*$/# Version ${VERSION}/g" -if [[ "$OSTYPE" == "darwin"* ]]; then - # Mac OSX - find ./CPAC/resources/configs -name "*.yml" -exec sed -i '' -E "${_SED_COMMAND}" {} \; +log_info() { + echo "=== $* ===" +} + +# ------------------------------------------------------------------------- +# Main +# ------------------------------------------------------------------------- + +START_DIR=$(pwd) +SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" +REPO_ROOT="$(realpath "$SCRIPT_DIR/../..")" + +# ------------------------------------------------------------------------- +# Fetch version +# ------------------------------------------------------------------------- +log_info "Fetching version" +VERSION=$(python -c "import sys; sys.path.insert(0, '$REPO_ROOT/CPAC'); from info import __version__; print(__version__.split('+', 1)[0])") +VERSION_FILE="$REPO_ROOT/version" +if [[ -f "$VERSION_FILE" ]]; then + cd "$REPO_ROOT" + OLD_VERSION=$(git show "$(git log --pretty=format:'%h' -n 1 -- version | tail -n 1)":version) + cd "$START_DIR" else - # Linux and others - find ./CPAC/resources/configs -name "*.yml" -exec sed -i'' -r "${_SED_COMMAND}" {} \; + OLD_VERSION="" fi -wait_for_git_lock && git add version -VERSIONS=( `git show $(git log --pretty=format:'%h' -n 1 version | tail -n 1):version` `cat version` ) -export PATTERN="(declare|typeset) -a" -if [[ "$(declare -p VERSIONS)" =~ $PATTERN ]] -then - for DOCKERFILE in $(find ./.github/Dockerfiles -name "*.Dockerfile") - do - export IFS="" - for LINE in $(grep "FROM ghcr\.io/fcp\-indi/c\-pac/.*\-${VERSIONS[0]}" ${DOCKERFILE}) - do - echo "Updating stage tags in ${DOCKERFILE}" +echo "v${VERSION}" > "$VERSION_FILE" + +# ------------------------------------------------------------------------- +# Write version file and stage it +# ------------------------------------------------------------------------- +log_info "Updating version file" +if update_file_if_changed "" <(echo "v${VERSION}") "$VERSION_FILE"; then + git_add_with_retry "$VERSION_FILE" +fi + +# ------------------------------------------------------------------------- +# Update YAML config files +# ------------------------------------------------------------------------- +log_info "Updating YAML config files" +VERSION_EXPR="s/^(# [Vv]ersion ).*$/# Version ${VERSION}/g" +for YAML_FILE in "$REPO_ROOT"/CPAC/resources/configs/{*.yml,*.yaml,test_configs/*.yml,test_configs/*.yaml}; do + [[ -e "$YAML_FILE" ]] || continue + + echo "Processing ${YAML_FILE}" + echo "Applying regex: ${VERSION_EXPR}" + + # Run sed safely + tmp=$(mktemp) + if ! sed -E "$VERSION_EXPR" "$YAML_FILE" > "$tmp"; then + echo "❌ sed failed on $YAML_FILE" + rm "$tmp" + exit 1 + fi + + if ! cmp -s "$tmp" "$YAML_FILE"; then + mv "$tmp" "$YAML_FILE" + echo "Updated $YAML_FILE" + git_add_with_retry "$YAML_FILE" + else + rm "$tmp" + echo "No changes needed for $YAML_FILE" + fi +done + +# ------------------------------------------------------------------------- +# Update Dockerfiles (only C-PAC tags) +# ------------------------------------------------------------------------- +log_info "Updating Dockerfiles" +NEW_VERSION=$(<"$VERSION_FILE") + +if [[ "$OLD_VERSION" != "$NEW_VERSION" ]]; then + for DOCKERFILE in "$REPO_ROOT"/.github/Dockerfiles/*.Dockerfile; do + if grep -q "FROM ghcr\.io/fcp-indi/c-pac/.*-${OLD_VERSION}" "$DOCKERFILE"; then + echo "Updating C-PAC version in ${DOCKERFILE} from ${OLD_VERSION} to ${NEW_VERSION}" + if [[ "$OSTYPE" == "darwin"* ]]; then - # Mac OSX - sed -i "" "s/\-${VERSIONS[0]}/\-${VERSIONS[1]}/g" ${DOCKERFILE} + # macOS sed + sed -i "" "s/-${OLD_VERSION}/-${NEW_VERSION}/g" "$DOCKERFILE" else - # Linux and others - sed -i "s/\-${VERSIONS[0]}/\-${VERSIONS[1]}/g" ${DOCKERFILE} + # Linux sed + sed -i -E "s/-${OLD_VERSION}/-${NEW_VERSION}/g" "$DOCKERFILE" fi - done + + git_add_with_retry "$DOCKERFILE" + fi done - unset IFS fi -wait_for_git_lock && git add CPAC/resources/configs .github/Dockerfiles - -# Overwrite top-level Dockerfiles with the CI Dockerfiles -wait_for_git_lock && cp .github/Dockerfiles/C-PAC.develop-jammy.Dockerfile Dockerfile -wait_for_git_lock && cp .github/Dockerfiles/C-PAC.develop-lite-jammy.Dockerfile variant-lite.Dockerfile -for DOCKERFILE in $(ls *Dockerfile) -do - wait_for_git_lock && git add $DOCKERFILE + +# ------------------------------------------------------------------------- +# Overwrite top-level Dockerfiles +# ------------------------------------------------------------------------- +log_info "Updating top-level Dockerfiles" +TOP_DOCKERFILES=( + ".github/Dockerfiles/C-PAC.develop-jammy.Dockerfile:Dockerfile" + ".github/Dockerfiles/C-PAC.develop-lite-jammy.Dockerfile:variant-lite.Dockerfile" +) +for SRC_DST in "${TOP_DOCKERFILES[@]}"; do + # Split SRC_DST by colon safely + SRC="${SRC_DST%%:*}" + DST="${SRC_DST##*:}" + + FULL_SRC="$REPO_ROOT/$SRC" + FULL_DST="$REPO_ROOT/$DST" + + if [[ ! -f "$FULL_SRC" ]]; then + echo "⚠️ Source Dockerfile does not exist: $FULL_SRC" + continue + fi + echo "Updating top-level Dockerfile: $FULL_DST from $FULL_SRC" + cp "$FULL_SRC" "$FULL_DST" && git_add_with_retry "$FULL_DST" done + +# Return to original directory +cd "$START_DIR" + +# ------------------------------------------------------------------------- +# Summary +# ------------------------------------------------------------------------- +echo +echo "Version changed: (from ${OLD_VERSION} to ${NEW_VERSION})" +echo "======================" diff --git a/.github/scripts/get_package_id.py b/.github/scripts/get_package_id.py index 0cc8e8aa57..d289f30418 100644 --- a/.github/scripts/get_package_id.py +++ b/.github/scripts/get_package_id.py @@ -83,6 +83,8 @@ def fetch(url): ] ) ) + if isinstance(response, dict): + response = [response] return response _packages = fetch( diff --git a/.github/workflows/build_C-PAC.yml b/.github/workflows/build_C-PAC.yml index d126f6a778..3ab3918a14 100644 --- a/.github/workflows/build_C-PAC.yml +++ b/.github/workflows/build_C-PAC.yml @@ -1,5 +1,14 @@ name: Build C-PAC image +permissions: + checks: write + contents: read + deployments: write + issues: write + packages: write + pull-requests: write + statuses: write + on: workflow_call: inputs: @@ -13,20 +22,20 @@ jobs: runs-on: ubuntu-latest steps: - name: Maximize build space - uses: easimon/maximize-build-space@v6 + uses: easimon/maximize-build-space@v10 with: remove-dotnet: 'true' remove-android: 'true' remove-haskell: 'true' overprovision-lvm: 'true' - name: Check out C-PAC - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 2 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2.2.1 + uses: docker/setup-buildx-action@v3.9.0 - name: Log in to GitHub Container Registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} @@ -87,7 +96,7 @@ jobs: echo $DOCKERFILE cat $DOCKERFILE - name: Build and push Docker image - uses: docker/build-push-action@v4.0.0 + uses: docker/build-push-action@v6.13.0 with: context: . file: ${{ env.DOCKERFILE }} diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yaml similarity index 90% rename from .github/workflows/build_and_test.yml rename to .github/workflows/build_and_test.yaml index 6dadd8f9f9..5c2e337788 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yaml @@ -17,7 +17,13 @@ name: Build and test C-PAC permissions: + checks: write + contents: read + deployments: write + issues: write packages: write + pull-requests: write + statuses: write on: workflow_call: @@ -46,6 +52,10 @@ on: description: 'third phase of staging images to rebuild (base images)' type: string required: true + test_mode: + description: 'lite or full?' + type: string + default: None jobs: Ubuntu: @@ -58,7 +68,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out C-PAC - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set tag & see if it exists @@ -80,17 +90,17 @@ jobs: sudo rm -rf "$AGENT_TOOLSDIRECTORY" - name: Set up Docker Buildx if: contains(fromJSON(env.REBUILD), matrix.Dockerfile) || steps.docker_tag.outputs.not_yet_exists == 1 - uses: docker/setup-buildx-action@v2.2.1 + uses: docker/setup-buildx-action@v3.9.0 - name: Log in to GitHub Container Registry if: contains(fromJSON(env.REBUILD), matrix.Dockerfile) || steps.docker_tag.outputs.not_yet_exists == 1 - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push Docker image if: contains(fromJSON(env.REBUILD), matrix.Dockerfile) || steps.docker_tag.outputs.not_yet_exists == 1 - uses: docker/build-push-action@v4.0.0 + uses: docker/build-push-action@v6.13.0 with: file: .github/Dockerfiles/${{ matrix.Dockerfile }}.Dockerfile push: true @@ -110,7 +120,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out C-PAC - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set tag & see if it exists @@ -140,17 +150,17 @@ jobs: sudo rm -rf "$AGENT_TOOLSDIRECTORY" - name: Set up Docker Buildx if: contains(fromJSON(env.REBUILD), matrix.Dockerfile) || steps.docker_tag.outputs.not_yet_exists == 1 - uses: docker/setup-buildx-action@v2.2.1 + uses: docker/setup-buildx-action@v3.9.0 - name: Log in to GitHub Container Registry if: contains(fromJSON(env.REBUILD), matrix.Dockerfile) || steps.docker_tag.outputs.not_yet_exists == 1 - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push Docker image if: contains(fromJSON(env.REBUILD), matrix.Dockerfile) || steps.docker_tag.outputs.not_yet_exists == 1 - uses: docker/build-push-action@v4.0.0 + uses: docker/build-push-action@v6.13.0 with: context: . file: .github/Dockerfiles/${{ matrix.Dockerfile }}.Dockerfile @@ -172,21 +182,21 @@ jobs: variant: ${{ fromJSON(inputs.phase_three) }} steps: - name: Maximize build space - uses: easimon/maximize-build-space@v6 + uses: easimon/maximize-build-space@v10 with: remove-dotnet: 'true' remove-android: 'true' remove-haskell: 'true' overprovision-lvm: 'true' - name: Check out C-PAC - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - name: Prep source files run: | sed -i -e 's/^/\.github\/Dockerfiles\//' .github/stage_requirements/${{ matrix.variant }}.txt echo 'dev/docker_data/required_afni_pkgs.txt' >> .github/stage_requirements/${{ matrix.variant }}.txt - echo '.github/workflows/build_and_test.yml' >> .github/stage_requirements/${{ matrix.variant }}.txt + echo '.github/workflows/build_and_test.yaml' >> .github/stage_requirements/${{ matrix.variant }}.txt echo '.github/stage_requirements/${{ matrix.variant }}.txt' >> .github/stage_requirements/${{ matrix.variant }}.txt - name: Set tag & see if it exists continue-on-error: true @@ -215,17 +225,17 @@ jobs: sudo rm -rf "$AGENT_TOOLSDIRECTORY" - name: Set up Docker Buildx if: contains(fromJSON(env.REBUILD), matrix.variant) || steps.docker_tag.outputs.not_yet_exists == 1 - uses: docker/setup-buildx-action@v2.2.1 + uses: docker/setup-buildx-action@v3.9.0 - name: Log in to GitHub Container Registry if: contains(fromJSON(env.REBUILD), matrix.variant) || steps.docker_tag.outputs.not_yet_exists == 1 - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push base image if: contains(fromJSON(env.REBUILD), matrix.variant) || steps.docker_tag.outputs.not_yet_exists == 1 - uses: docker/build-push-action@v4.0.0 + uses: docker/build-push-action@v6.13.0 with: context: . file: .github/Dockerfiles/base-${{ matrix.variant }}.Dockerfile @@ -244,21 +254,21 @@ jobs: REBUILD: ${{ inputs.rebuild_phase_three }} steps: - name: Maximize build space - uses: easimon/maximize-build-space@v6 + uses: easimon/maximize-build-space@v10 with: remove-dotnet: 'true' remove-android: 'true' remove-haskell: 'true' overprovision-lvm: 'true' - name: Check out C-PAC - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - name: Prep source files run: | sed -i -e 's/^/\.github\/Dockerfiles\//' .github/stage_requirements/standard.txt echo 'dev/docker_data/required_afni_pkgs.txt' >> .github/stage_requirements/standard.txt - echo '.github/workflows/build_and_test.yml' >> .github/stage_requirements/standard.txt + echo '.github/workflows/build_and_test.yaml' >> .github/stage_requirements/standard.txt echo '.github/stage_requirements/standard.txt' >> .github/stage_requirements/standard.txt - name: Set tag & see if it exists continue-on-error: true @@ -287,17 +297,17 @@ jobs: sudo rm -rf "$AGENT_TOOLSDIRECTORY" - name: Set up Docker Buildx if: contains(fromJSON(env.REBUILD), 'standard') || steps.docker_tag.outputs.not_yet_exists == 1 - uses: docker/setup-buildx-action@v2.2.1 + uses: docker/setup-buildx-action@v3.9.0 - name: Log in to GitHub Container Registry if: contains(fromJSON(env.REBUILD), 'standard') || steps.docker_tag.outputs.not_yet_exists == 1 - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push base image if: contains(fromJSON(env.REBUILD), 'standard') || steps.docker_tag.outputs.not_yet_exists == 1 - uses: docker/build-push-action@v4.0.0 + uses: docker/build-push-action@v6.13.0 with: context: . file: .github/Dockerfiles/base-standard.Dockerfile @@ -327,19 +337,21 @@ jobs: if: github.ref_name == 'develop' || github.ref_name == 'main' uses: ./.github/workflows/smoke_test_participant.yml - regtest-lite: - name: Run lite regression test + check_test_mode: + name: check_test_mode + runs-on: ubuntu-latest + steps: + - run: echo ${{ inputs.test_mode }} + + regtest: + name: Run regression and integration test needs: - C-PAC secrets: inherit - if: contains(github.event.head_commit.message, '[run reg-suite]') - uses: ./.github/workflows/regression_test_lite.yml - - regtest-full: - name: Run full regression test - needs: - - smoke-tests-participant - uses: ./.github/workflows/regression_test_full.yml + if: inputs.test_mode == 'lite' + uses: ./.github/workflows/regtest.yaml + with: + test_mode: ${{ inputs.test_mode }} Circle_tests: name: Run tests on CircleCI @@ -350,7 +362,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out C-PAC - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 2 - name: Trigger CircleCI tests diff --git a/.github/workflows/delete_images.yml b/.github/workflows/delete_images.yml index 91ed5e98df..e981581511 100644 --- a/.github/workflows/delete_images.yml +++ b/.github/workflows/delete_images.yml @@ -1,5 +1,9 @@ name: Delete development images +permissions: + contents: read + packages: write + on: delete: @@ -14,12 +18,14 @@ jobs: - '' - lite env: + DELETED_BRANCH: ${{ github.event.ref }} GITHUB_TOKEN: ${{ secrets.API_PACKAGE_READ_DELETE }} IMAGE: c-pac steps: - name: Check out C-PAC - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: 'Delete branch image' + continue-on-error: true run: | OWNER=$(echo ${GITHUB_REPOSITORY} | cut -d '/' -f 1) if [[ $(curl -u ${GITHUB_TOKEN}: https://api.github.com/users/${OWNER} | jq '.type') == '"User"' ]] @@ -28,11 +34,12 @@ jobs: else OWNER_TYPE=org fi - if [[ "${{ inputs.variant }}" != "" ]] + echo "OWNER_TYPE=${OWNER_TYPE:0:4}" >> $GITHUB_ENV + if [[ "${{ matrix.variant }}" != "" ]] then - VARIANT=-${{ inputs.variant }} + VARIANT="-${{ matrix.variant }}" fi - TAG=${GITHUB_REF_NAME} + TAG=${DELETED_BRANCH//\//_} TAG=$TAG$VARIANT VERSION_ID=$(python .github/scripts/get_package_id.py $OWNER $IMAGE $TAG) @@ -41,10 +48,14 @@ jobs: -X DELETE \ https://api.github.com/${OWNER_TYPE}/${OWNER}/packages/container/c-pac/versions/${VERSION_ID} - name: Delete all containers from repository without tags - uses: Chizkiyahu/delete-untagged-ghcr-action@v2 + if: matrix.variant == '' + env: + OWNER_TYPE: ${{ env.OWNER_TYPE }} + uses: Chizkiyahu/delete-untagged-ghcr-action@v6 with: - token: ${GITHUB_TOKEN} + token: ${{ secrets.API_PACKAGE_READ_DELETE }} repository_owner: ${{ github.repository_owner }} repository: ${{ github.repository }} untagged_only: true - owner_type: org + except_untagged_multiplatform: false + owner_type: ${{ env.OWNER_TYPE }} diff --git a/.github/workflows/deploy_to_Docker_Hub.yml b/.github/workflows/deploy_to_Docker_Hub.yml index a9aaec8fab..f6cb13d13e 100644 --- a/.github/workflows/deploy_to_Docker_Hub.yml +++ b/.github/workflows/deploy_to_Docker_Hub.yml @@ -1,5 +1,14 @@ name: Deploy to Docker Hub +permissions: + checks: write + contents: read + deployments: write + issues: write + packages: write + pull-requests: write + statuses: write + on: workflow_call: inputs: @@ -18,7 +27,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Log in to Docker Hub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USER }} password: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/on_push.yml b/.github/workflows/on_push.yaml similarity index 77% rename from .github/workflows/on_push.yml rename to .github/workflows/on_push.yaml index 60f6354dc5..4fa3518b00 100644 --- a/.github/workflows/on_push.yml +++ b/.github/workflows/on_push.yaml @@ -16,6 +16,15 @@ # License along with C-PAC. If not, see . name: Build and test C-PAC +permissions: + checks: write + contents: read + deployments: write + issues: write + packages: write + pull-requests: write + statuses: write + on: push: @@ -32,16 +41,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out C-PAC - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 2 - - name: Get changed files since last commit - uses: tj-actions/changed-files@v41.0.0 - id: changed-files - with: - since_last_remote_commit: "true" - files: .github/Dockerfiles/* - json: "true" - name: Determine stages to rebuild env: MESSAGE: ${{ github.event.head_commit.message }} @@ -49,14 +51,12 @@ jobs: run: | # initialize phase arrays declare -a PHASE_ONE PHASE_TWO PHASE_THREE REBUILD_PHASE_ONE REBUILD_PHASE_TWO REBUILD_PHASE_THREE - # turn JSON array into BASH array - CHANGED_FILES=( $(echo ${{ steps.changed-files.outputs.all_changed_files }} | sed -e 's/\[//g' -e 's/\]//g' -e 's/\,/ /g') ) # loop through stages to maybe rebuild for STAGE in $(cat ${GITHUB_WORKSPACE}/.github/stage_requirements/phase_one.txt) do PHASE_ONE+=($STAGE) # check commit message for [rebuild STAGE] or if STAGE has changed - if [[ "${MESSAGE}" == *"[rebuild ${STAGE}]"* ]] || [[ " ${CHANGED_FILES[*]} " =~ " ${STAGE} " ]] + if [[ "${MESSAGE}" == *"[rebuild ${STAGE}]"* ]] then REBUILD_PHASE_ONE+=($STAGE) fi @@ -64,7 +64,7 @@ jobs: for STAGE in $(cat ${GITHUB_WORKSPACE}/.github/stage_requirements/phase_two.txt) do PHASE_TWO+=($STAGE) - if [[ "${MESSAGE}" == *"[rebuild ${STAGE}]"* ]] || [[ " ${CHANGED_FILES[*]} " =~ " ${STAGE} " ]] + if [[ "${MESSAGE}" == *"[rebuild ${STAGE}]"* ]] then REBUILD_PHASE_TWO+=($STAGE) fi @@ -72,14 +72,14 @@ jobs: for STAGE in $(cat ${GITHUB_WORKSPACE}/.github/stage_requirements/phase_three.txt) do PHASE_THREE+=($STAGE) - if [[ "${MESSAGE}" == *"[rebuild ${STAGE}]"* ]] || [[ "${MESSAGE}" == *"[rebuild base-${STAGE}]"* ]] || [[ " ${CHANGED_FILES[*]} " =~ " ${STAGE} " ]] + if [[ "${MESSAGE}" == *"[rebuild ${STAGE}]"* ]] || [[ "${MESSAGE}" == *"[rebuild base-${STAGE}]"* ]] then REBUILD_PHASE_THREE+=($STAGE) fi done # add base stages based on their dependencies BASES=("${PHASE_THREE[@]}" standard) - if [[ "${MESSAGE}" == *"[rebuild standard]"* ]] || [[ "${MESSAGE}" == *"[rebuild base-standard]"* ]] || [[ " ${CHANGED_FILES[*]} " =~ " standard " ]] + if [[ "${MESSAGE}" == *"[rebuild standard]"* ]] || [[ "${MESSAGE}" == *"[rebuild base-standard]"* ]] then REBUILD_PHASE_THREE+=(standard) fi @@ -107,10 +107,36 @@ jobs: echo "phase_three=${phase_three}" >> $GITHUB_OUTPUT echo "rebuild_phase_three=${rebuild_phase_three}" >> $GITHUB_OUTPUT + check_pr: + runs-on: ubuntu-latest + outputs: + test_mode: ${{ steps.check_pr.outputs.test_mode }} + steps: + - name: Check out C-PAC + uses: actions/checkout@v3 + with: + fetch-depth: 2 + - name: Check if commit is in a PR to develop + id: check_pr + run: | + TEST_MODE=none + if echo "${{ github.event.head_commit.message }}" | grep -q '\[run reg-suite lite\]' + then + TEST_MODE=lite + elif gh pr list --base develop --json number,state,draft | jq 'any(.[]; .state == "OPEN" or .draft == true)'; then + TEST_MODE=lite + elif gh pr list --base main --json number,state,draft | jq 'any(.[]; .state == "OPEN" or .draft == true)'; then + TEST_MODE=full + fi + echo "test_mode=${TEST_MODE}" + echo "test_mode=${TEST_MODE}" >> $GITHUB_OUTPUT + build-stages: name: Build multistage image stages - needs: check-updated-preconfigs - uses: ./.github/workflows/build_and_test.yml + needs: + - check_pr + - check-updated-preconfigs + uses: ./.github/workflows/build_and_test.yaml secrets: inherit with: phase_one: ${{ needs.check-updated-preconfigs.outputs.phase_one }} @@ -119,3 +145,4 @@ jobs: rebuild_phase_two: ${{ needs.check-updated-preconfigs.outputs.rebuild_phase_two }} phase_three: ${{ needs.check-updated-preconfigs.outputs.phase_three }} rebuild_phase_three: ${{ needs.check-updated-preconfigs.outputs.rebuild_phase_three }} + test_mode: ${{ needs.check_pr.outputs.test_mode }} diff --git a/.github/workflows/regression_test_full.yml b/.github/workflows/regression_test_full.yml deleted file mode 100644 index 6dba2d1bf2..0000000000 --- a/.github/workflows/regression_test_full.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Run Regression Full Test - -on: - workflow_call: - -jobs: - test: - name: Regression Test - Full - runs-on: ubuntu-latest - steps: - - name: Get C-PAC branch - run: | - GITHUB_BRANCH=$(echo ${GITHUB_REF} | cut -d '/' -f 3-) - if [[ ! $GITHUB_BRANCH == 'main' ]] && [[ ! $GITHUB_BRANCH == 'develop' ]] - then - TAG=${GITHUB_BRANCH//\//_} - elif [[ $GITHUB_BRANCH == 'develop' ]] - then - TAG=nightly - elif [[ $GITHUB_BRANCH == 'main' ]] - then - TAG=latest - fi - - - name: Checkout Code - uses: actions/checkout@v2 - - name: Clone reg-suite - run: | - git clone https://github.com/amygutierrez/reg-suite.git - - - name: Run Full Regression Test - if: ${{ github.event_name }} == "pull_request" && ${{ github.event.pull_request.state }} == "closed" && ${{ github.event.pull_request.merged }} == "true" && ${{ github.event.pull_request.base.ref }} == "main" - run: | - echo "Running full regression test" - echo "୧(๑•̀ヮ•́)૭ LET'S GO! ٩(^ᗜ^ )و " - - - uses: actions/upload-artifact@v3 - if: always() - with: - name: logs - path: output/*/*/log/ diff --git a/.github/workflows/regression_test_lite.yml b/.github/workflows/regression_test_lite.yml deleted file mode 100644 index 4e6b5a46f6..0000000000 --- a/.github/workflows/regression_test_lite.yml +++ /dev/null @@ -1,88 +0,0 @@ -name: Launch lite regression test - -on: - pull_request: - branches: - - develop - types: - - opened - - ready_for_review - - reopened - workflow_call: - secrets: - GH_CLI_BIN_PATH: - description: 'path to directory containing GitHub CLI binary if not on default $PATH' - required: false - SSH_PRIVATE_KEY: - required: true - SSH_USER: - required: true - SSH_HOST: - required: true - SSH_WORK_DIR: - required: true - workflow_dispatch: - -jobs: - test: - name: Regression Test - Lite - environment: ACCESS - env: - SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} - SSH_HOST: ${{ secrets.SSH_HOST }} - if: "${{ github.env.SSH_PRIVATE_KEY }} != ''" - runs-on: ubuntu-latest - steps: - - name: Get C-PAC branch - run: | - if [[ ! $GITHUB_REF_NAME == 'main' ]] && [[ ! $GITHUB_REF_NAME == 'develop' ]] - then - TAG=${GITHUB_REF_NAME//\//_} - elif [[ $GITHUB_REF_NAME == 'develop' ]] - then - TAG=nightly - elif [[ $GITHUB_REF_NAME == 'main' ]] - then - TAG=latest - fi - TAG=$TAG$VARIANT - echo DOCKER_TAG=$(echo "ghcr.io/${{ github.repository }}" | tr '[:upper:]' '[:lower:]'):$TAG >> $GITHUB_ENV - cat $GITHUB_ENV - - - name: Install SSH Keys - run: | - mkdir -p ~/.ssh/ - echo "${{ env.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa - chmod 600 ~/.ssh/id_rsa - ssh-keyscan -H -t rsa "${{ env.SSH_HOST }}" > ~/.ssh/known_hosts - - - name: Initiate check - uses: guibranco/github-status-action-v2@v1.1.7 - with: - authToken: ${{ secrets.GITHUB_TOKEN }} - context: Launch lite regression test - description: launching - state: pending - - - name: Connect and Run Regression Test Lite - uses: appleboy/ssh-action@v1.0.0 - with: - host: ${{ secrets.SSH_HOST }} - username: ${{ secrets.SSH_USER }} - key: ${{ secrets.SSH_PRIVATE_KEY }} - command_timeout: 200m - script: | - cd ${{ secrets.SSH_WORK_DIR }} - if [ ! -d slurm_testing ] ; then - git clone https://github.com/${{ github.repository_owner }}/slurm_testing slurm_testing - else - cd slurm_testing - git pull origin regression/after_runs - cd .. - fi - mkdir -p ./logs/${{ github.sha }} - sbatch --export="HOME_DIR=${{ secrets.SSH_WORK_DIR }},IMAGE=${{ env.DOCKER_TAG }},OWNER=${{ github.repository_owner }},PATH_EXTRA=${{ secrets.GH_CLI_BIN_PATH }},REPO=$(echo ${{ github.repository }} | cut -d '/' -f 2),SHA=${{ github.sha }}" --output=${{ secrets.SSH_WORK_DIR }}/logs/${{ github.sha }}/out.log --error=${{ secrets.SSH_WORK_DIR }}/logs/${{ github.sha }}/error.log slurm_testing/.github/scripts/launch_regtest_lite.SLURM - - - name: Cleanup SSH - run: | - rm -rf ~/.ssh diff --git a/.github/workflows/regtest.yaml b/.github/workflows/regtest.yaml new file mode 100644 index 0000000000..04c6b14d15 --- /dev/null +++ b/.github/workflows/regtest.yaml @@ -0,0 +1,116 @@ +name: Launch regression test + +on: + pull_request: + branches: + - develop + types: + - opened + - ready_for_review + - reopened + workflow_call: + inputs: + test_mode: + type: string + required: true + secrets: + GH_CLI_BIN_PATH: + description: 'path to directory containing GitHub CLI binary if not on default $PATH' + required: false + SSH_PRIVATE_KEY: + required: true + SSH_USER: + required: true + SSH_HOST: + required: true + SSH_WORK_DIR: + required: true + workflow_dispatch: + inputs: + test_mode: + type: string + required: true + +jobs: + test: + name: Regression Test - ${{ inputs.test_mode }} + environment: ACCESS + env: + COMPARISON_PATH: ${{ secrets.COMPARISON_PATH }} + DASHBOARD_REPO: ${{ vars.DASHBOARD_REPO}} + DOCKER_TAG: + GH_CLI_BIN_PATH: ${{ secrets.GH_CLI_BIN_PATH }} + SLURM_TESTING_BRANCH: ${{ vars.SLURM_TESTING_BRANCH }} + SLURM_TESTING_PACKAGE: ${{ vars.SLURM_TESTING_PACKAGE }} + SLURM_TESTING_REPO: ${{ vars.SLURM_TESTING_REPO }} + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + SSH_HOST: ${{ secrets.SSH_HOST }} + SSH_USER: ${{ secrets.SSH_USER }} + SSH_WORK_DIR: ${{ secrets.SSH_WORK_DIR }} + TOKEN_FILE: ${{ secrets.TOKEN_FILE }} + if: | + ${{ github.env.SSH_PRIVATE_KEY != '' && + (github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && + github.event.repository.fork == 'false')) }} + runs-on: ubuntu-latest + steps: + - name: Get C-PAC branch + run: | + if [[ ! $GITHUB_REF_NAME == 'main' ]] && [[ ! $GITHUB_REF_NAME == 'develop' ]] + then + TAG=${GITHUB_REF_NAME//\//_} + elif [[ $GITHUB_REF_NAME == 'develop' ]] + then + TAG=nightly + elif [[ $GITHUB_REF_NAME == 'main' ]] + then + TAG=latest + fi + TAG=$TAG$VARIANT + echo DOCKER_TAG=$(echo "ghcr.io/${{ github.repository }}" | tr '[:upper:]' '[:lower:]'):$TAG >> $GITHUB_ENV + cat $GITHUB_ENV + + - name: Install SSH Keys + run: | + mkdir -p ~/.ssh/ + echo "${{ env.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -H -t rsa "${{ env.SSH_HOST }}" > ~/.ssh/known_hosts + + - name: Connect and Run Regression Test ${{ inputs.test_mode }} + uses: appleboy/ssh-action@v1.0.0 + with: + host: ${{ env.SSH_HOST }} + username: ${{ env.SSH_USER }} + key: ${{ env.SSH_PRIVATE_KEY }} + command_timeout: 200m + script: | + set -x + cd ${{ env.SSH_WORK_DIR }} + if pip show "${{ env.SLURM_TESTING_PACKAGE }}" > /dev/null 2>&1; then + # If the package is installed, upgrade it + python3 -m pip install --user --upgrade --force-reinstall "https://github.com/${{ env.SLURM_TESTING_REPO }}/archive/${{ env.SLURM_TESTING_BRANCH }}.zip" + else + # If the package is not installed, install it + python3 -m pip install --user "https://github.com/${{ env.SLURM_TESTING_REPO }}/archive/${{ env.SLURM_TESTING_BRANCH }}.zip" + fi + _CPAC_SLURM_TESTING_WD="${{ env.SSH_WORK_DIR }}/automatic_tests/${{ inputs.test_mode }}/${{ github.sha }}" + mkdir -p "${_CPAC_SLURM_TESTING_WD}" + sbatch cpac-slurm-status ${{ inputs.test_mode }} launch \ + --wd="${_CPAC_SLURM_TESTING_WD}" \ + --comparison-path="${{ env.COMPARISON_PATH }}" \ + --dashboard-repo="${{ env.DASHBOARD_REPO}}" \ + --home-dir="${{ env.SSH_WORK_DIR }}" \ + --image="${{ env.DOCKER_TAG }}" \ + --owner="${{ github.repository_owner }}" \ + --path-extra="${{ env.GH_CLI_BIN_PATH }}" \ + --repo="${{ github.repository }}" \ + --sha="${{ github.sha }}" \ + --slurm-testing-branch="${{ env.SLURM_TESTING_BRANCH }}" \ + --slurm-testing-repo="${{ env.SLURM_TESTING_REPO }}" \ + --token-file="${{ env.TOKEN_FILE }}" + + - name: Cleanup SSH + run: | + rm -rf ~/.ssh diff --git a/.github/workflows/smoke_test_participant.yml b/.github/workflows/smoke_test_participant.yml index 3fde0de8aa..dc4206d602 100644 --- a/.github/workflows/smoke_test_participant.yml +++ b/.github/workflows/smoke_test_participant.yml @@ -16,6 +16,15 @@ # License along with C-PAC. If not, see . name: Run participant smoke test +permissions: + checks: write + contents: read + deployments: write + issues: write + packages: write + pull-requests: write + statuses: write + on: workflow_call: workflow_dispatch: @@ -104,7 +113,7 @@ jobs: --participant_label ${{ matrix.participant }} \ --preconfig ${{ matrix.preconfig }} \ --n_cpus 2 - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: ${{ always() }} with: name: expectedOutputs human ${{ matrix.preconfig }} ${{ matrix.variant }} ${{ matrix.participant }} @@ -144,14 +153,14 @@ jobs: TAG=$TAG$VARIANT echo DOCKER_TAG=$(echo "ghcr.io/${{ github.repository }}" | tr '[:upper:]' '[:lower:]'):$TAG >> $GITHUB_ENV cat $GITHUB_ENV + - name: setup-conda + uses: conda-incubator/setup-miniconda@v3.1.1 - name: Set up datalad-OSF run: | + sudo apt-get update && sudo apt-get install -y git-annex git config --global user.email "CMI_CPAC_Support@childmind.org" git config --global user.name "Theodore (Machine User)" - wget -O- http://neuro.debian.net/lists/jammy.us-tn.libre | sudo tee /etc/apt/sources.list.d/neurodebian.sources.list - sudo apt-key adv --recv-keys --keyserver hkps://keyserver.ubuntu.com 0xA5D32F012649A5A9 - sudo apt-get update - sudo apt-get install datalad git-annex-standalone + yes | conda install -c conda-forge datalad pip install datalad-osf - name: Get NHP test data run: | @@ -168,7 +177,7 @@ jobs: --preconfig ${{ matrix.preconfig }} \ --participant_label ${{ matrix.participant }} \ --n_cpus 2 - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: ${{ always() }} with: name: expectedOutputs nhp ${{ matrix.preconfig }} ${{ matrix.variant }} ${{ matrix.participant }} @@ -203,17 +212,24 @@ jobs: TAG=$TAG$VARIANT echo DOCKER_TAG=$(echo "ghcr.io/${{ github.repository }}" | tr '[:upper:]' '[:lower:]'):$TAG >> $GITHUB_ENV cat $GITHUB_ENV + - name: setup-conda + uses: conda-incubator/setup-miniconda@v3.1.1 + with: + activate-environment: datalad-osf + channels: conda-forge + conda-remove-defaults: "true" + python-version: 3.12 - name: Set up datalad-OSF run: | + sudo apt-get update && sudo apt-get install -y git-annex git config --global user.email "CMI_CPAC_Support@childmind.org" git config --global user.name "Theodore (Machine User)" - wget -O- http://neuro.debian.net/lists/jammy.us-tn.libre | sudo tee /etc/apt/sources.list.d/neurodebian.sources.list - sudo apt-key adv --recv-keys --keyserver hkps://keyserver.ubuntu.com 0xA5D32F012649A5A9 - sudo apt-get update - sudo apt-get install datalad git-annex-standalone + yes | conda install -c conda-forge datalad pip install datalad-osf - name: Get rodent test data run: | + export GIT_TRACE=1 + export DATALAD_LOG_LEVEL=DEBUG datalad clone osf://uya3r test-data - name: Run rodent smoke test run: | @@ -226,7 +242,7 @@ jobs: /test-data /outputs test_config \ --preconfig rodent \ --n_cpus 2 - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: ${{ always() }} with: name: expectedOutputs rodent ${{ matrix.variant }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 957e36b029..fd93639282 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,6 +14,9 @@ # You should have received a copy of the GNU Lesser General Public # License along with C-PAC. If not, see . +ci: + skip: [ruff, update-yaml-comments] + fail_fast: false repos: @@ -66,7 +69,7 @@ repos: name: Update Dockerfiles and version comments entry: .github/scripts/autoversioning.sh language: script - files: '.*Dockerfile$|.*\.yaml$|^CPAC/info\.py$' + files: '(^CPAC/info\.py$|.*Dockerfile$|.*\.ya?ml$)' - id: update-yaml-comments name: Update YAML comments entry: CPAC/utils/configuration/yaml_template.py diff --git a/.ruff.toml b/.ruff.toml index 265427a1ab..1dda55a299 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -20,7 +20,6 @@ external = ["T20"] # Don't autoremove 'noqa` comments for these rules "nibabel" = "nib" "nipype.interfaces.io" = "nio" "networkx" = "nx" -"pkg_resources" = "p" "CPAC.pipeline.nipype_pipeline_engine" = "pe" [lint.isort] diff --git a/CHANGELOG.md b/CHANGELOG.md index df8f40a666..68164c1da7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,15 +19,71 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `pyproject.toml` file with `[build-system]` defined. +- [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/FCP-INDI/C-PAC/main.svg)](https://results.pre-commit.ci/latest/github/FCP-INDI/C-PAC/main) badge to [`README`](./README.md). +- `desired_orientation` key in participant-level pipeline config under `pipeline_setup`. +- Required positional parameter "wf" in input and output of `ingress_pipeconfig_paths` function, where a node to reorient templates is added to the `wf`. +- Required positional parameter "orientation" to `resolve_resolution`. +- Optional positional argument "cfg" to `create_lesion_preproc`. +- Allow enabling `overwrite_transform` only when the registration method is `ANTS`. +- `resource_inventory` utility to inventory NodeBlock function inputs and outputs. +- New switch `mask_sbref` under `func_input_prep` in functional registration and set to default `on`. +- New resource `desc-head_bold` as non skull-stripped bold from nodeblock `bold_masking`. +- `censor_file_path` from `offending_timepoints_connector` in the `build_nuisance_regressor` node. +- Switch `sink_native_transforms` under `registration_workflows` to output all `.mat` files in ANTs and FSL Transforms. +- `deoblique` field in pipeline config with `warp` and `refit` options to apply `3dWarp` or `3drefit` during data initialization. +- `organism` configuration option. +- Functionality to convert `space-T1w_desc-loose_brain_mask` and `space-T1w_desc-tight_brain_mask` into generic brain mask `space-T1w_desc-brain_mask` to use in brain extraction nodeblock downstream. +- `desc-ABCDpreproc_T1w` to the outputs +- `bc` to `lite` container images. +- Robust method to classify `fmap` types reading in the json metadata during workflow build process. +- validation node to match the pixdim4 of CPAC processed bold outputs with the original raw bold sources. ### Changed - Moved `pygraphviz` from requirements to `graphviz` optional dependencies group. +- Automatically tag untagged `subject_id` and `unique_id` as `!!str` when loading data config files. +- Made orientation configurable (was hard-coded as "RPI"). +- Resource-not-found errors now include information about where to source those resources. +- Moved `ref_mask_res_2` and `T1w_template_res-2` fields from registration into surface under `abcd_prefreesurfer_prep`. +- Moved `find_censors node` inside `create_nuisance_regression_workflow` into its own function/subworkflow as `offending_timepoints_connector`. +- [FSL-AFNI subworkflow](https://github.com/FCP-INDI/C-PAC/blob/4bdd6c410ef0a9b90f53100ea005af1f7d6e76c0/CPAC/func_preproc/func_preproc.py#L1052C4-L1231C25) + - Moved `FSL-AFNI subworkflow` from inside a `bold_mask_fsl_afni` nodeblock into a separate function. + - Renamed `desc-ref_bold` created in this workflow to `desc-unifized_bold`. + - `coregistration_prep_fmriprep` nodeblock now checks if `desc-unifized_bold` exists in the Resource Pool, if not it runs the `FSL-AFNI subworkflow` to create it. +- Input `desc-brain_bold` to `desc-preproc_bold` for `sbref` generation nodeblock `coregistration_prep_vol`. +- Turned `generate_xcpqc_files` on for all preconfigurations except `blank`. +- Introduced specific switch `restore_t1w_intensity` for `correct_restore_brain_intensity_abcd` nodeblock, enabling it by default only in `abcd-options` pre-config. +- Updated GitHub Actions to run automated integration and regression tests on HPC. +- Refactored `bold_mask_anatomical_resampled` nodeblock and related pipeline configs: + - Limited scope to template-space masking only. + - Removed broken support for native-space masking. + - Introduced a new `template_space_func_masking` section in the pipeline config for template-space-only methods. + - Moved `Anatomical_Resampled` masking method from `func_masking` to the `template_space_func_masking`. + - Upgraded resource retrieval to `importlib.resources`. +- Turned `On` boundary_based_registration for abcd-options preconfig. +- Refactored `transform_timeseries_to_T1template_abcd` nodeblock removing unnecessary nodes, changing `desc-preproc_T1w` inputs as reference to `desc-head_T1w`. +- Appended `T1w to Template` FOV match transform to the XFM. + +### Upgraded + +- `requests` 2.32.0 → 2.32.3 ### Fixed - A bug in which AWS S3 encryption was looked for in Nipype config instead of pipeline config (only affected uploading logs). - Restored `bids-validator` functionality. +- Fixed empty `shell` variable in cluster run scripts. +- A bug in which bandpass filters always assumed 1D regressor files have exactly 5 header rows. +- Removed an erroneous connection to AFNI 3dTProject in nuisance denoising that would unnecessarily send a spike regressor as a censor. This would sometimes cause TRs to unnecessarily be dropped from the timeseries as if scrubbing were being performed. +- Lingering calls to `cpac_outputs.csv` (was changed to `cpac_outputs.tsv` in v1.8.1). +- A bug in the `freesurfer_abcd_preproc` nodeblock where the `Template` image was incorrectly used as `reference` during the `inverse_warp` step. Replacing it with the subject-specific `T1w` image resolved the issue of the `desc-restoreBrain_T1w` being chipped off. +- A bug in `ideal_bandpass` where the frequency mask was incorrectly applied, which caused filter to fail in certain cases. +- `Freesufer-ABCD` brain masking strategy to create mask as per the original DCAN script. +- A bug where `$ANTSPATH` was unset in C-PAC with FreeSurfer images. + +### Upgraded dependencies + +- `voluptuous` 0.13.1 → 0.15.2 ### Removed @@ -35,6 +91,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ABCD-HCP` - `fMRIPrep-LTS` - Typehinting support for Python < 3.10. +- Extra outputs listed in `freesurfer_abcd_preproc`. +- Resource `space-template_desc-T1w_mask` + - as output from FNIRT registration. + - as inputs from Nodeblocks requesting it and, replaced with `space-template_desc-brain_mask`. + - from outputs tsv. +- Inputs `[desc-motion_bold, bold]` from `coregistration_prep_vol` nodeblock. +- `input` field from `coregistration` in blank and default config. +- `reg_with_skull` swtich from `func_input_prep` in blank and default config. +- Support for AFNI 3dECM < v21.1.1. +- `calculate_motion_before` and `calculate_motion_after` configuration options. + +#### Removed CI dependency + +- `tj-actions/changed-files` ([CVE-2023-51664](https://www.stepsecurity.io/blog/harden-runner-detection-tj-actions-changed-files-action-is-compromised)) ## [1.8.7] - 2024-05-03 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 24b37bcd47..2f54c2a947 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,3 +80,4 @@ We have 3 types of staging Dockerfiles: operating system, software dependency, a * To change a dependency in a C-PAC image, update the stage images at the top of the relevant `.github/Dockerfiles/C-PAC.develop-*.Dockerfile`. * If a Dockerfile does not yet exist for the added dependency, create a Dockerfile for the new dependency and add the filename (without extension) to [`jobs.stages.strategy.matrix.Dockerfile` in `.github/workflows/build_stages.yml`](https://github.com/FCP-INDI/C-PAC/blob/4e18916384e52c3dc9610aea3eed537c19d480e3/.github/workflows/build_stages.yml#L77-L97) * If no Dockerfiles use the removed dependency, remove the Dockerfile for the dependency and remove the filename from [`jobs.stages.strategy.matrix.Dockerfile` in `.github/workflows/build_stages.yml`](https://github.com/FCP-INDI/C-PAC/blob/4e18916384e52c3dc9610aea3eed537c19d480e3/.github/workflows/build_stages.yml#L77-L97) +* When making changes to a Dockerfile, include the line `[rebuild {filename}]` where `filename` is the name of the Dockerfile without the extension (e.g., `[rebuild Ubuntu.jammy-non-free]`). diff --git a/CPAC/__init__.py b/CPAC/__init__.py index 1aaa8bb066..346fc897f6 100644 --- a/CPAC/__init__.py +++ b/CPAC/__init__.py @@ -35,10 +35,14 @@ def _docs_prefix() -> str: return DOCS_URL_PREFIX -license_notice = f"""Copyright (C) 2022-2024 C-PAC Developers. +def license_notice() -> str: + """Get the license notice for this version.""" + return f"""Copyright (C) 2022-2024 C-PAC Developers. This program comes with ABSOLUTELY NO WARRANTY. This is free software, and you are welcome to redistribute it under certain conditions. For details, see {_docs_prefix()}/license or the COPYING and COPYING.LESSER files included in the source code.""" + + __all__ = ["license_notice", "version", "__version__"] diff --git a/CPAC/__main__.py b/CPAC/__main__.py index 90eb435b23..535adf83fa 100644 --- a/CPAC/__main__.py +++ b/CPAC/__main__.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# Copyright (C) 2018-2024 C-PAC Developers +# Copyright (C) 2018-2025 C-PAC Developers # This file is part of C-PAC. @@ -15,18 +15,18 @@ # You should have received a copy of the GNU Lesser General Public # License along with C-PAC. If not, see . -from logging import basicConfig, INFO +"""C-PAC CLI.""" + import os import click from click_aliases import ClickAliasedGroup -import pkg_resources as p +from CPAC.resources.configs import CONFIGS_PATH from CPAC.utils.docs import version_report from CPAC.utils.monitoring.custom_logging import getLogger logger = getLogger("CPAC") -basicConfig(format="%(message)s", level=INFO) # CLI tree # @@ -71,71 +71,44 @@ def version(): ) +def _config_path(filename: str) -> str: + """Given a base filename, return full config path.""" + return str(CONFIGS_PATH / f"{filename}") + + @main.command() @click.argument("data_config") @click.option("--pipe-config", "--pipe_config") @click.option("--num-cores", "--num_cores") @click.option("--ndmg-mode", "--ndmg_mode", is_flag=True) @click.option("--debug", is_flag=True) -def run(data_config, pipe_config=None, num_cores=None, ndmg_mode=False, debug=False): - if not pipe_config: - pipe_config = p.resource_filename( - "CPAC", os.path.join("resources", "configs", "pipeline_config_template.yml") - ) - - if pipe_config == "benchmark-ants": - pipe_config = p.resource_filename( - "CPAC", - os.path.join("resources", "configs", "pipeline_config_benchmark-ANTS.yml"), - ) - - if pipe_config == "benchmark-fnirt": - pipe_config = p.resource_filename( - "CPAC", - os.path.join("resources", "configs", "pipeline_config_benchmark-FNIRT.yml"), - ) - - if pipe_config == "anat-only": - pipe_config = p.resource_filename( - "CPAC", - os.path.join("resources", "configs", "pipeline_config_anat-only.yml"), - ) - - if data_config == "benchmark-data": - data_config = p.resource_filename( - "CPAC", - os.path.join("resources", "configs", "data_config_cpac_benchmark.yml"), - ) - - if data_config == "ADHD200": - data_config = p.resource_filename( - "CPAC", - os.path.join("resources", "configs", "data_config_S3-BIDS-ADHD200.yml"), - ) - if data_config == "ADHD200_2": - data_config = p.resource_filename( - "CPAC", - os.path.join( - "resources", "configs", "data_config_S3-BIDS-ADHD200_only2.yml" - ), - ) - if data_config == "ABIDE": - data_config = p.resource_filename( - "CPAC", - os.path.join("resources", "configs", "data_config_S3-BIDS-ABIDE.yml"), - ) - if data_config == "NKI-RS": - data_config = p.resource_filename( - "CPAC", - os.path.join( - "resources", "configs", "data_config_S3-BIDS-NKI-RocklandSample.yml" - ), - ) - +def run( + data_config, pipe_config=None, num_cores=None, ndmg_mode=False, debug=False +) -> None: + """Run C-PAC.""" if ndmg_mode: - pipe_config = p.resource_filename( - "CPAC", os.path.join("resources", "configs", "pipeline_config_ndmg.yml") - ) + pipe_config = _config_path("pipeline_config_ndmg") + else: + match pipe_config: + case None: + pipe_config = _config_path("pipeline_config_template") + case "benchmark-ants": + pipe_config = _config_path("pipeline_config_benchmark-ANTS") + case "benchmark-fnirt": + pipe_config = _config_path("pipeline_config_benchmark-FNIRT") + case "anat-only": + pipe_config = _config_path("pipeline_config_anat-only") + match data_config: + case "benchmark-data": + data_config = _config_path("data_config_cpac_benchmark") + case "ADHD200": + data_config = _config_path("data_config_S3-BIDS-ADHD200") + case "ADHD200_2": + data_config = _config_path("data_config_S3-BIDS-ADHD200_only2") + case "ABIDE": + data_config = _config_path("data_config_S3-BIDS-ABIDE") + case "NKI-RS": + data_config = _config_path("data_config_S3-BIDS-NKI-RocklandSample") from CPAC.pipeline import cpac_runner @@ -567,36 +540,16 @@ def test(): def run_suite(show_list: bool | str = False, pipeline_filter=""): from CPAC.pipeline import cpac_runner - test_config_dir = p.resource_filename( - "CPAC", os.path.join("resources", "configs", "test_configs") - ) - - data_test = p.resource_filename( - "CPAC", - os.path.join( - "resources", "configs", "test_configs", "data-test_S3-ADHD200_1.yml" - ), - ) - - data_test_no_scan_param = p.resource_filename( - "CPAC", - os.path.join( - "resources", "configs", "test_configs", "data-test_S3-ADHD200_no-params.yml" - ), - ) - - data_test_fmap = p.resource_filename( - "CPAC", - os.path.join( - "resources", "configs", "test_configs", "data-test_S3-NKI-RS_fmap.yml" - ), - ) + test_config_dir = CONFIGS_PATH / "test_configs" + data_test = test_config_dir / "data-test_S3-ADHD200_1" + data_test_no_scan_param = test_config_dir / "data-test_S3-ADHD200_no-params" + data_test_fmap = test_config_dir / "data-test_S3-NKI-RS_fmap" if show_list: show_list = "\nAvailables pipelines:" no_params = False - for config_file in os.listdir(test_config_dir): + for config_file in [str(_) for _ in test_config_dir.iterdir()]: if config_file.startswith("pipe-test_"): if pipeline_filter not in config_file: continue @@ -605,7 +558,7 @@ def run_suite(show_list: bool | str = False, pipeline_filter=""): show_list += f"\n- {config_file[len('pipe-test_'):]}" continue - pipe = os.path.join(test_config_dir, config_file) + pipe = str(test_config_dir / config_file) if "DistCorr" in pipe: data = data_test_fmap diff --git a/CPAC/_entrypoints/run.py b/CPAC/_entrypoints/run.py index ffeb1c8352..ac68c7dfb3 100755 --- a/CPAC/_entrypoints/run.py +++ b/CPAC/_entrypoints/run.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# Copyright (C) 2018-2024 C-PAC Developers +# Copyright (C) 2018-2025 C-PAC Developers # This file is part of C-PAC. @@ -112,7 +112,7 @@ def resolve_aws_credential(source: Path | str) -> str: def run_main(): """Run this function if not importing as a script.""" parser = argparse.ArgumentParser( - description="C-PAC Pipeline Runner. " + license_notice + description="C-PAC Pipeline Runner. " + license_notice() ) parser.add_argument( "bids_dir", @@ -454,6 +454,14 @@ def run_main(): action="store_true", ) + parser.add_argument( + "--freesurfer_dir", + "--freesurfer-dir", + help="Specify path to pre-computed FreeSurfer outputs " + "to pull into C-PAC run", + default=False, + ) + # get the command line arguments args = parser.parse_args( sys.argv[1 : (sys.argv.index("--") if "--" in sys.argv else len(sys.argv))] @@ -474,15 +482,10 @@ def run_main(): elif args.analysis_level == "group": if not args.group_file or not os.path.exists(args.group_file): - import pkg_resources as p + from CPAC.resources.configs import CONFIGS_PATH WFLOGGER.warning("\nNo group analysis configuration file was supplied.\n") - - args.group_file = p.resource_filename( - "CPAC", - os.path.join("resources", "configs", "group_config_template.yml"), - ) - + args.group_file = str(CONFIGS_PATH / "group_config_template.yml") output_group = os.path.join(output_dir, "group_config.yml") try: @@ -721,16 +724,6 @@ def run_main(): args.fail_fast ) - if c["pipeline_setup"]["output_directory"]["quality_control"][ - "generate_xcpqc_files" - ]: - c["functional_preproc"]["motion_estimates_and_correction"][ - "motion_estimates" - ]["calculate_motion_first"] = True - c["functional_preproc"]["motion_estimates_and_correction"][ - "motion_estimates" - ]["calculate_motion_after"] = True - if args.participant_label: WFLOGGER.info( "#### Running C-PAC for %s", ", ".join(args.participant_label) @@ -743,6 +736,9 @@ def run_main(): c["pipeline_setup", "system_config", "num_participants_at_once"], ) + if args.freesurfer_dir: + c["pipeline_setup"]["freesurfer_dir"] = args.freesurfer_dir + if not args.data_config_file: WFLOGGER.info("Input directory: %s", bids_dir) @@ -783,9 +779,8 @@ def run_main(): sub_list = load_cpac_data_config( args.data_config_file, args.participant_label, args.aws_input_creds ) - list(sub_list) sub_list = sub_list_filter_by_labels( - sub_list, {"T1w": args.T1w_label, "bold": args.bold_label} + list(sub_list), {"T1w": args.T1w_label, "bold": args.bold_label} ) # C-PAC only handles single anatomical images (for now) diff --git a/CPAC/_global_fixtures.py b/CPAC/_global_fixtures.py new file mode 100644 index 0000000000..7b765736ee --- /dev/null +++ b/CPAC/_global_fixtures.py @@ -0,0 +1,34 @@ +# Copyright (C) 2025 C-PAC Developers + +# This file is part of C-PAC. + +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. + +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . +"""Global fixtures for C-PAC tests.""" + +from pathlib import Path + +from _pytest.tmpdir import TempPathFactory +from git import Repo +import pytest + + +@pytest.fixture(scope="session") +def bids_examples(tmp_path_factory: TempPathFactory) -> Path: + """Get the BIDS examples dataset.""" + example_dir = tmp_path_factory.mktemp("bids-examples") + if not example_dir.exists() or not any(example_dir.iterdir()): + Repo.clone_from( + "https://github.com/bids-standard/bids-examples.git", str(example_dir) + ) + return example_dir diff --git a/CPAC/alff/alff.py b/CPAC/alff/alff.py index f8bfc1a0b8..a66791b83c 100644 --- a/CPAC/alff/alff.py +++ b/CPAC/alff/alff.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Copyright (C) 2012-2024 C-PAC Developers +# Copyright (C) 2012-2025 C-PAC Developers # This file is part of C-PAC. @@ -25,7 +25,6 @@ from CPAC.pipeline.nodeblock import nodeblock from CPAC.registration.registration import apply_transform from CPAC.utils.interfaces import Function -from CPAC.utils.utils import check_prov_for_regtool def create_alff(wf_name="alff_workflow"): @@ -320,10 +319,7 @@ def alff_falff(wf, cfg, strat_pool, pipe_num, opt=None): def alff_falff_space_template(wf, cfg, strat_pool, pipe_num, opt=None): outputs = {} if strat_pool.check_rpool("desc-denoisedNofilt_bold"): - xfm_prov = strat_pool.get_cpac_provenance( - "from-bold_to-template_mode-image_xfm" - ) - reg_tool = check_prov_for_regtool(xfm_prov) + reg_tool = strat_pool.reg_tool("from-bold_to-template_mode-image_xfm") num_cpus = cfg.pipeline_setup["system_config"]["max_cores_per_participant"] diff --git a/CPAC/anat_preproc/anat_preproc.py b/CPAC/anat_preproc/anat_preproc.py index 0f4e770f97..2cf19ced7e 100644 --- a/CPAC/anat_preproc/anat_preproc.py +++ b/CPAC/anat_preproc/anat_preproc.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Copyright (C) 2012-2023 C-PAC Developers +# Copyright (C) 2012-2025 C-PAC Developers # This file is part of C-PAC. @@ -28,7 +28,6 @@ fsl_aff_to_rigid, fslmaths_command, mri_convert, - normalize_wmparc, pad, VolumeRemoveIslands, wb_command, @@ -37,6 +36,7 @@ from CPAC.pipeline.nodeblock import nodeblock from CPAC.utils.interfaces import Function from CPAC.utils.interfaces.fsl import Merge as fslMerge +from CPAC.utils.utils import afni_3dwarp def acpc_alignment( @@ -695,13 +695,15 @@ def afni_brain_connector(wf, cfg, strat_pool, pipe_num, opt): wf.connect(anat_skullstrip, "out_file", anat_brain_mask, "in_file_a") + outputs = {} + if strat_pool.check_rpool("desc-preproc_T1w"): outputs = {"space-T1w_desc-brain_mask": (anat_brain_mask, "out_file")} elif strat_pool.check_rpool("desc-preproc_T2w"): outputs = {"space-T2w_desc-brain_mask": (anat_brain_mask, "out_file")} - return (wf, outputs) + return wf, outputs def fsl_brain_connector(wf, cfg, strat_pool, pipe_num, opt): @@ -1127,37 +1129,12 @@ def freesurfer_abcd_brain_connector(wf, cfg, strat_pool, pipe_num, opt): name=f"wmparc_to_nifti_{pipe_num}", ) - # Register wmparc file if ingressing FreeSurfer data - if strat_pool.check_rpool("pipeline-fs_xfm"): - wmparc_to_native = pe.Node( - Function( - input_names=["source_file", "target_file", "xfm", "out_file"], - output_names=["transformed_file"], - function=normalize_wmparc, - ), - name=f"wmparc_to_native_{pipe_num}", - ) - - wmparc_to_native.inputs.out_file = "wmparc_warped.mgz" - - node, out = strat_pool.get_data("pipeline-fs_wmparc") - wf.connect(node, out, wmparc_to_native, "source_file") - - node, out = strat_pool.get_data("pipeline-fs_raw-average") - wf.connect(node, out, wmparc_to_native, "target_file") - - node, out = strat_pool.get_data("pipeline-fs_xfm") - wf.connect(node, out, wmparc_to_native, "xfm") - - wf.connect(wmparc_to_native, "transformed_file", wmparc_to_nifti, "in_file") - - else: - node, out = strat_pool.get_data("pipeline-fs_wmparc") - wf.connect(node, out, wmparc_to_nifti, "in_file") + node, out = strat_pool.get_data("pipeline-fs_wmparc") + wf.connect(node, out, wmparc_to_nifti, "in_file") wmparc_to_nifti.inputs.args = "-rt nearest" - node, out = strat_pool.get_data("desc-preproc_T1w") + node, out = strat_pool.get_data(["desc-restore_T1w", "desc-preproc_T1w"]) wf.connect(node, out, wmparc_to_nifti, "reslice_like") binary_mask = pe.Node( @@ -1193,7 +1170,7 @@ def freesurfer_abcd_brain_connector(wf, cfg, strat_pool, pipe_num, opt): wf.connect(binary_filled_mask, "out_file", brain_mask_to_t1_restore, "in_file") - node, out = strat_pool.get_data("desc-preproc_T1w") + node, out = strat_pool.get_data(["desc-restore_T1w", "desc-preproc_T1w"]) wf.connect(node, out, brain_mask_to_t1_restore, "ref_file") outputs = {"space-T1w_desc-brain_mask": (brain_mask_to_t1_restore, "out_file")} @@ -1233,7 +1210,7 @@ def freesurfer_fsl_brain_connector(wf, cfg, strat_pool, pipe_num, opt): mem_gb=0, mem_x=(0.0115, "in_file", "t"), ) - reorient_fs_brainmask.inputs.orientation = "RPI" + reorient_fs_brainmask.inputs.orientation = cfg.pipeline_setup["desired_orientation"] reorient_fs_brainmask.inputs.outputtype = "NIFTI_GZ" wf.connect( @@ -1255,7 +1232,7 @@ def freesurfer_fsl_brain_connector(wf, cfg, strat_pool, pipe_num, opt): mem_gb=0, mem_x=(0.0115, "in_file", "t"), ) - reorient_fs_T1.inputs.orientation = "RPI" + reorient_fs_T1.inputs.orientation = cfg.pipeline_setup["desired_orientation"] reorient_fs_T1.inputs.outputtype = "NIFTI_GZ" wf.connect(convert_fs_T1_to_nifti, "out_file", reorient_fs_T1, "in_file") @@ -1302,7 +1279,7 @@ def freesurfer_fsl_brain_connector(wf, cfg, strat_pool, pipe_num, opt): # fslmaths tmp_mask.nii.gz -mas ${CCSDIR}/templates/MNI152_T1_1mm_first_brain_mask.nii.gz tmp_mask.nii.gz apply_mask = pe.Node(interface=fsl.maths.ApplyMask(), name=f"apply_mask_{node_id}") - wf.connect(skullstrip, "out_file", apply_mask, "in_file") + wf.connect(skullstrip, "mask_file", apply_mask, "in_file") node, out = strat_pool.get_data("T1w-brain-template-mask-ccs") wf.connect(node, out, apply_mask, "mask_file") @@ -1347,39 +1324,28 @@ def freesurfer_fsl_brain_connector(wf, cfg, strat_pool, pipe_num, opt): wf.connect(combine_mask, "out_file", binarize_combined_mask, "in_file") # CCS brain mask is in FS space, transfer it back to native T1 space - fs_fsl_brain_mask_to_native = pe.Node( - interface=freesurfer.ApplyVolTransform(), - name=f"fs_fsl_brain_mask_to_native_{node_id}", + match_fov_ccs_brain_mask = pe.Node( + interface=fsl.FLIRT(), name=f"match_fov_CCS_brain_mask_{node_id}" ) - fs_fsl_brain_mask_to_native.inputs.reg_header = True - fs_fsl_brain_mask_to_native.inputs.interp = "nearest" + match_fov_ccs_brain_mask.inputs.apply_xfm = True + match_fov_ccs_brain_mask.inputs.uses_qform = True + match_fov_ccs_brain_mask.inputs.interp = "nearestneighbour" + node, out = strat_pool.get_data("pipeline-fs_raw-average") + convert_fs_T1_to_nifti = pe.Node( + Function( + input_names=["in_file"], output_names=["out_file"], function=mri_convert + ), + name=f"convert_fs_T1_to_nifti_for_ccs_{node_id}", + ) + wf.connect(node, out, convert_fs_T1_to_nifti, "in_file") wf.connect( - binarize_combined_mask, "out_file", fs_fsl_brain_mask_to_native, "source_file" + convert_fs_T1_to_nifti, "out_file", match_fov_ccs_brain_mask, "reference" ) - node, out = strat_pool.get_data("pipeline-fs_raw-average") - wf.connect(node, out, fs_fsl_brain_mask_to_native, "target_file") + wf.connect(binarize_combined_mask, "out_file", match_fov_ccs_brain_mask, "in_file") - node, out = strat_pool.get_data("freesurfer-subject-dir") - wf.connect(node, out, fs_fsl_brain_mask_to_native, "subjects_dir") - - if opt == "FreeSurfer-BET-Tight": - outputs = { - "space-T1w_desc-tight_brain_mask": ( - fs_fsl_brain_mask_to_native, - "transformed_file", - ) - } - elif opt == "FreeSurfer-BET-Loose": - outputs = { - "space-T1w_desc-loose_brain_mask": ( - fs_fsl_brain_mask_to_native, - "transformed_file", - ) - } - - return (wf, outputs) + return wf, {"space-T1w_desc-brain_mask": (match_fov_ccs_brain_mask, "out_file")} def mask_T2(wf_name="mask_T2"): @@ -1442,17 +1408,31 @@ def mask_T2(wf_name="mask_T2"): @nodeblock( name="anatomical_init", - config=["anatomical_preproc"], - switch=["run"], + switch=["anatomical_preproc", "run"], + option_key=["anatomical_preproc", "deoblique"], + option_val=["warp", "refit"], inputs=["T1w"], outputs=["desc-preproc_T1w", "desc-reorient_T1w", "desc-head_T1w"], ) def anatomical_init(wf, cfg, strat_pool, pipe_num, opt=None): - anat_deoblique = pe.Node(interface=afni.Refit(), name=f"anat_deoblique_{pipe_num}") - anat_deoblique.inputs.deoblique = True + if opt not in anatomical_init.option_val: + msg = f"\n[!] Error: Invalid option for deoblique: {opt}. \nExpected one of {anatomical_init.option_val}" + raise ValueError(msg) - node, out = strat_pool.get_data("T1w") - wf.connect(node, out, anat_deoblique, "in_file") + if opt == "warp": + anat_deoblique = pe.Node( + Function( + input_names=["in_file", "deoblique"], + output_names=["out_file"], + function=afni_3dwarp, + ), + name=f"anat_deoblique_warp_{pipe_num}", + ) + + elif opt == "refit": + anat_deoblique = pe.Node( + interface=afni.Refit(), name=f"anat_deoblique_refit_{pipe_num}" + ) anat_reorient = pe.Node( interface=afni.Resample(), @@ -1460,11 +1440,15 @@ def anatomical_init(wf, cfg, strat_pool, pipe_num, opt=None): mem_gb=0, mem_x=(0.0115, "in_file", "t"), ) - anat_reorient.inputs.orientation = "RPI" - anat_reorient.inputs.outputtype = "NIFTI_GZ" + node, out = strat_pool.get_data("T1w") + anat_deoblique.inputs.deoblique = True + wf.connect(node, out, anat_deoblique, "in_file") wf.connect(anat_deoblique, "out_file", anat_reorient, "in_file") + anat_reorient.inputs.orientation = cfg.pipeline_setup["desired_orientation"] + anat_reorient.inputs.outputtype = "NIFTI_GZ" + outputs = { "desc-preproc_T1w": (anat_reorient, "out_file"), "desc-reorient_T1w": (anat_reorient, "out_file"), @@ -1523,7 +1507,7 @@ def acpc_align_head(wf, cfg, strat_pool, pipe_num, opt=None): ( "desc-head_T1w", "desc-preproc_T1w", - ["space-T1w_desc-brain_mask", "space-T1w_desc-brain_mask"], + "space-T1w_desc-brain_mask", ), "T1w-ACPC-template", "T1w-brain-ACPC-template", @@ -1531,7 +1515,7 @@ def acpc_align_head(wf, cfg, strat_pool, pipe_num, opt=None): outputs=[ "desc-head_T1w", "desc-preproc_T1w", - ["space-T1w_desc-brain_mask", "space-T1w_desc-brain_mask"], + "space-T1w_desc-brain_mask", "from-T1w_to-ACPC_mode-image_desc-aff2rig_xfm", ], ) @@ -1962,193 +1946,102 @@ def brain_mask_acpc_unet(wf, cfg, strat_pool, pipe_num, opt=None): ["anatomical_preproc", "run"], ], option_key=["anatomical_preproc", "brain_extraction", "using"], - option_val="FreeSurfer-Brainmask", - inputs=[ - "pipeline-fs_raw-average", - "pipeline-fs_brainmask", - "freesurfer-subject-dir", - ], - outputs=["space-T1w_desc-brain_mask"], -) -def brain_mask_freesurfer(wf, cfg, strat_pool, pipe_num, opt=None): - wf, outputs = freesurfer_brain_connector(wf, cfg, strat_pool, pipe_num, opt) - - return (wf, outputs) - - -@nodeblock( - name="brain_mask_acpc_freesurfer", - switch=[ - ["anatomical_preproc", "brain_extraction", "run"], - ["anatomical_preproc", "run"], - ], - option_key=["anatomical_preproc", "brain_extraction", "using"], - option_val="FreeSurfer-Brainmask", - inputs=[ - "space-T1w_desc-brain_mask", - "pipeline-fs_raw-average", - "freesurfer-subject-dir", - ], - outputs=["space-T1w_desc-acpcbrain_mask"], -) -def brain_mask_acpc_freesurfer(wf, cfg, strat_pool, pipe_num, opt=None): - wf, wf_outputs = freesurfer_brain_connector(wf, cfg, strat_pool, pipe_num, opt) - - outputs = {"space-T1w_desc-acpcbrain_mask": wf_outputs["space-T1w_desc-brain_mask"]} - - return (wf, outputs) - - -@nodeblock( - name="brain_mask_freesurfer_abcd", - switch=[ - ["anatomical_preproc", "brain_extraction", "run"], - ["anatomical_preproc", "run"], + option_val=[ + "FreeSurfer-ABCD", + "FreeSurfer-BET-Loose", + "FreeSurfer-BET-Tight", + "FreeSurfer-Brainmask", ], - option_key=["anatomical_preproc", "brain_extraction", "using"], - option_val="FreeSurfer-ABCD", - inputs=[ - "desc-preproc_T1w", - "pipeline-fs_wmparc", - "pipeline-fs_raw-average", - "pipeline-fs_xfm", - "freesurfer-subject-dir", - ], - outputs=["space-T1w_desc-brain_mask"], -) -def brain_mask_freesurfer_abcd(wf, cfg, strat_pool, pipe_num, opt=None): - wf, outputs = freesurfer_abcd_brain_connector(wf, cfg, strat_pool, pipe_num, opt) - - return (wf, outputs) - - -@nodeblock( - name="brain_mask_freesurfer_fsl_tight", - switch=[ - ["anatomical_preproc", "brain_extraction", "run"], - ["anatomical_preproc", "run"], - ], - option_key=["anatomical_preproc", "brain_extraction", "using"], - option_val="FreeSurfer-BET-Tight", inputs=[ - "pipeline-fs_brainmask", - "pipeline-fs_T1", - "pipeline-fs_raw-average", - "freesurfer-subject-dir", + ( + ["desc-restore_T1w", "desc-preproc_T1w"], + "space-T1w_desc-brain_mask", + "pipeline-fs_T1", + "pipeline-fs_wmparc", + "pipeline-fs_raw-average", + "pipeline-fs_brainmask", + "freesurfer-subject-dir", + ), "T1w-brain-template-mask-ccs", "T1w-ACPC-template", ], - outputs=["space-T1w_desc-tight_brain_mask"], -) -def brain_mask_freesurfer_fsl_tight(wf, cfg, strat_pool, pipe_num, opt=None): - wf, outputs = freesurfer_fsl_brain_connector(wf, cfg, strat_pool, pipe_num, opt) - - return (wf, outputs) - - -@nodeblock( - name="brain_mask_acpc_freesurfer_abcd", - switch=[ - ["anatomical_preproc", "brain_extraction", "run"], - ["anatomical_preproc", "run"], - ], - option_key=["anatomical_preproc", "brain_extraction", "using"], - option_val="FreeSurfer-ABCD", - inputs=[ - "desc-preproc_T1w", - "pipeline-fs_wmparc", - "pipeline-fs_raw-average", - "pipeline-fs_xfm", - "freesurfer-subject-dir", - ], - outputs=["space-T1w_desc-acpcbrain_mask"], + outputs={"space-T1w_desc-brain_mask": {}}, ) -def brain_mask_acpc_freesurfer_abcd(wf, cfg, strat_pool, pipe_num, opt=None): - wf, wf_outputs = freesurfer_abcd_brain_connector(wf, cfg, strat_pool, pipe_num, opt) - - outputs = {"space-T1w_desc-acpcbrain_mask": wf_outputs["space-T1w_desc-brain_mask"]} - - return (wf, outputs) +def brain_mask_freesurfer(wf, cfg, strat_pool, pipe_num, opt=None): + assert isinstance(brain_mask_freesurfer.outputs, dict) + brain_mask_freesurfer.outputs["space-T1w_desc-brain_mask"] = { + "Description": f"Brain mask extracted using {opt} method", + "Method": opt, + } + match opt: + case "FreeSurfer-ABCD": + return freesurfer_abcd_brain_connector(wf, cfg, strat_pool, pipe_num, opt) + case "FreeSurfer-BET-Loose" | "FreeSurfer-BET-Tight": + brain_mask_freesurfer.outputs["space-T1w_desc-brain_mask"]["Threshold"] = ( + opt.rsplit("-")[-1].lower() + ) + return freesurfer_fsl_brain_connector(wf, cfg, strat_pool, pipe_num, opt) + case "FreeSurfer-Brainmask": + return freesurfer_brain_connector(wf, cfg, strat_pool, pipe_num, opt) + return wf, {} @nodeblock( - name="brain_mask_freesurfer_fsl_loose", + name="brain_mask_acpc_freesurfer", switch=[ ["anatomical_preproc", "brain_extraction", "run"], ["anatomical_preproc", "run"], ], option_key=["anatomical_preproc", "brain_extraction", "using"], - option_val="FreeSurfer-BET-Loose", - inputs=[ - "pipeline-fs_brainmask", - "pipeline-fs_T1", - "pipeline-fs_raw-average", - "freesurfer-subject-dir", - "T1w-brain-template-mask-ccs", - "T1w-ACPC-template", + option_val=[ + "FreeSurfer-ABCD", + "FreeSurfer-Brainmask", + "FreeSurfer-BET-Loose", + "FreeSurfer-BET-Tight", ], - outputs=["space-T1w_desc-loose_brain_mask"], -) -def brain_mask_freesurfer_fsl_loose(wf, cfg, strat_pool, pipe_num, opt=None): - wf, outputs = freesurfer_fsl_brain_connector(wf, cfg, strat_pool, pipe_num, opt) - - return (wf, outputs) - - -@nodeblock( - name="brain_mask_acpc_freesurfer_fsl_tight", - switch=[ - ["anatomical_preproc", "brain_extraction", "run"], - ["anatomical_preproc", "run"], - ], - option_key=["anatomical_preproc", "brain_extraction", "using"], - option_val="FreeSurfer-BET-Tight", inputs=[ - "pipeline-fs_brainmask", - "pipeline-fs_T1", - "T1w-brain-template-mask-ccs", + ( + ["desc-restore_T1w", "desc-preproc_T1w"], + "space-T1w_desc-brain_mask", + "space-T1w_desc-acpcbrain_mask", + "pipeline-fs_brainmask", + "pipeline-fs_raw-average", + "pipeline-fs_T1", + "pipeline-fs_wmparc", + "freesurfer-subject-dir", + ), "T1w-ACPC-template", - ], - outputs=["space-T1w_desc-tight_acpcbrain_mask"], -) -def brain_mask_acpc_freesurfer_fsl_tight(wf, cfg, strat_pool, pipe_num, opt=None): - wf, wf_outputs = freesurfer_fsl_brain_connector(wf, cfg, strat_pool, pipe_num, opt) - - outputs = { - "space-T1w_desc-tight_acpcbrain_mask": wf_outputs[ - "space-T1w_desc-tight_brain_mask" - ] - } - - return (wf, outputs) - - -@nodeblock( - name="brain_mask_acpc_freesurfer_fsl_loose", - switch=[ - ["anatomical_preproc", "brain_extraction", "run"], - ["anatomical_preproc", "run"], - ], - option_key=["anatomical_preproc", "brain_extraction", "using"], - option_val="FreeSurfer-BET-Loose", - inputs=[ - "pipeline-fs_brainmask", - "pipeline-fs_T1", "T1w-brain-template-mask-ccs", - "T1w-ACPC-template", ], - outputs=["space-T1w_desc-loose_acpcbrain_mask"], + outputs={"space-T1w_desc-acpcbrain_mask": {}}, ) -def brain_mask_acpc_freesurfer_fsl_loose(wf, cfg, strat_pool, pipe_num, opt=None): - wf, wf_outputs = freesurfer_fsl_brain_connector(wf, cfg, strat_pool, pipe_num, opt) - - outputs = { - "space-T1w_desc-loose_acpcbrain_mask": wf_outputs[ - "space-T1w_desc-loose_brain_mask" - ] +def brain_mask_acpc_freesurfer(wf, cfg, strat_pool, pipe_num, opt=None): + if opt != strat_pool.get_json("space-T1w_desc-brain_mask").get( + "CpacVariant", {} + ).get("space-T1w_mask", opt): + # https://tenor.com/baIhQ.gif + return wf, {} + assert isinstance(brain_mask_acpc_freesurfer.outputs, dict) + outputs = wf_outputs = {} + key = "space-T1w_desc-brain_mask" + functions = { + "FreeSurfer-ABCD": freesurfer_abcd_brain_connector, + "FreeSurfer-Brainmask": freesurfer_brain_connector, + "FreeSurfer-BET-Loose": freesurfer_fsl_brain_connector, + "FreeSurfer-BET-Tight": freesurfer_fsl_brain_connector, } + if opt in ["FreeSurfer-BET-Loose", "FreeSurfer-BET-Tight"]: + brain_mask_acpc_freesurfer.outputs["space-T1w_desc-acpcbrain_mask"] = { + "Description": f"Brain mask extracted using {opt} method", + "Method": opt, + "Threshold": opt.rsplit("-")[-1].lower(), + } + if opt in functions: + wf, wf_outputs = functions[opt](wf, cfg, strat_pool, pipe_num, opt) + if key in wf_outputs: + outputs = {"space-T1w_desc-acpcbrain_mask": wf_outputs[key]} - return (wf, outputs) + return wf, outputs @nodeblock( @@ -2167,7 +2060,6 @@ def brain_mask_acpc_freesurfer_fsl_loose(wf, cfg, strat_pool, pipe_num, opt=None outputs={ "desc-preproc_T1w": {"SkullStripped": "True"}, "desc-brain_T1w": {"SkullStripped": "True"}, - "desc-head_T1w": {"SkullStripped": "False"}, }, ) def brain_extraction(wf, cfg, strat_pool, pipe_num, opt=None): @@ -2205,7 +2097,6 @@ def brain_extraction(wf, cfg, strat_pool, pipe_num, opt=None): outputs = { "desc-preproc_T1w": (anat_skullstrip_orig_vol, "out_file"), "desc-brain_T1w": (anat_skullstrip_orig_vol, "out_file"), - "desc-head_T1w": (node_T1w, out_T1w), } return (wf, outputs) @@ -2250,17 +2141,32 @@ def brain_extraction_temp(wf, cfg, strat_pool, pipe_num, opt=None): @nodeblock( name="anatomical_init_T2", - config=["anatomical_preproc"], - switch=["run_t2"], + switch=["anatomical_preproc", "run_t2"], + option_key=["anatomical_preproc", "deoblique"], + option_val=["warp", "refit"], inputs=["T2w"], outputs=["desc-preproc_T2w", "desc-reorient_T2w", "desc-head_T2w"], ) def anatomical_init_T2(wf, cfg, strat_pool, pipe_num, opt=None): - T2_deoblique = pe.Node(interface=afni.Refit(), name=f"T2_deoblique_{pipe_num}") - T2_deoblique.inputs.deoblique = True + if opt not in anatomical_init_T2.option_val: + raise ValueError( + f"\n[!] Error: Invalid option for deoblique: {opt}. \nExpected one of {anatomical_init_T2.option_val}" + ) - node, out = strat_pool.get_data("T2w") - wf.connect(node, out, T2_deoblique, "in_file") + if opt == "warp": + T2_deoblique = pe.Node( + Function( + input_names=["in_file", "deoblique"], + output_names=["out_file"], + function=afni_3dwarp, + ), + name=f"T2_deoblique_warp_{pipe_num}", + ) + + elif opt == "refit": + T2_deoblique = pe.Node( + interface=afni.Refit(), name=f"T2_deoblique_refit_{pipe_num}" + ) T2_reorient = pe.Node( interface=afni.Resample(), @@ -2268,11 +2174,15 @@ def anatomical_init_T2(wf, cfg, strat_pool, pipe_num, opt=None): mem_gb=0, mem_x=(0.0115, "in_file", "t"), ) - T2_reorient.inputs.orientation = "RPI" - T2_reorient.inputs.outputtype = "NIFTI_GZ" + node, out = strat_pool.get_data("T2w") + T2_deoblique.inputs.deoblique = True + wf.connect(node, out, T2_deoblique, "in_file") wf.connect(T2_deoblique, "out_file", T2_reorient, "in_file") + T2_reorient.inputs.orientation = cfg.pipeline_setup["desired_orientation"] + T2_reorient.inputs.outputtype = "NIFTI_GZ" + outputs = { "desc-preproc_T2w": (T2_reorient, "out_file"), "desc-reorient_T2w": (T2_reorient, "out_file"), @@ -2572,7 +2482,7 @@ def brain_mask_acpc_niworkflows_ants_T2(wf, cfg, strat_pool, pipe_num, opt=None) config=["anatomical_preproc", "brain_extraction"], option_key="using", option_val="UNet", - inputs=["desc-preproc_T2w", "T1w-brain-template", "T1w-template", "unet_model"], + inputs=["desc-preproc_T2w", "T1w-brain-template", "T1w-template", "unet-model"], outputs=["space-T2w_desc-brain_mask"], ) def brain_mask_unet_T2(wf, cfg, strat_pool, pipe_num, opt=None): @@ -2586,7 +2496,7 @@ def brain_mask_unet_T2(wf, cfg, strat_pool, pipe_num, opt=None): config=["anatomical_preproc", "brain_extraction"], option_key="using", option_val="UNet", - inputs=["desc-preproc_T2w", "T1w-brain-template", "T1w-template", "unet_model"], + inputs=["desc-preproc_T2w", "T1w-brain-template", "T1w-template", "unet-model"], outputs=["space-T2w_desc-acpcbrain_mask"], ) def brain_mask_acpc_unet_T2(wf, cfg, strat_pool, pipe_num, opt=None): @@ -2764,24 +2674,6 @@ def brain_extraction_temp_T2(wf, cfg, strat_pool, pipe_num, opt=None): "desc-restore-brain_T1w", "desc-ABCDpreproc_T1w", "pipeline-fs_desc-fast_biasfield", - "pipeline-fs_hemi-L_desc-surface_curv", - "pipeline-fs_hemi-R_desc-surface_curv", - "pipeline-fs_hemi-L_desc-surfaceMesh_pial", - "pipeline-fs_hemi-R_desc-surfaceMesh_pial", - "pipeline-fs_hemi-L_desc-surfaceMesh_smoothwm", - "pipeline-fs_hemi-R_desc-surfaceMesh_smoothwm", - "pipeline-fs_hemi-L_desc-surfaceMesh_sphere", - "pipeline-fs_hemi-R_desc-surfaceMesh_sphere", - "pipeline-fs_hemi-L_desc-surfaceMap_sulc", - "pipeline-fs_hemi-R_desc-surfaceMap_sulc", - "pipeline-fs_hemi-L_desc-surfaceMap_thickness", - "pipeline-fs_hemi-R_desc-surfaceMap_thickness", - "pipeline-fs_hemi-L_desc-surfaceMap_volume", - "pipeline-fs_hemi-R_desc-surfaceMap_volume", - "pipeline-fs_hemi-L_desc-surfaceMesh_white", - "pipeline-fs_hemi-R_desc-surfaceMesh_white", - "pipeline-fs_wmparc", - "freesurfer-subject-dir", ], ) def freesurfer_abcd_preproc(wf, cfg, strat_pool, pipe_num, opt=None): @@ -2922,6 +2814,18 @@ def freesurfer_abcd_preproc(wf, cfg, strat_pool, pipe_num, opt=None): "pipeline-fs_brainmask", "pipeline-fs_wmparc", "pipeline-fs_T1", + *[ + f"pipeline-fs_hemi-{hemi}_{entity}" + for hemi in ["L", "R"] + for entity in [ + "desc-surface_curv", + *[ + f"desc-surfaceMesh_{_}" + for _ in ["pial", "smoothwm", "sphere", "white"] + ], + *[f"desc-surfaceMap_{_}" for _ in ["sulc", "thickness", "volume"]], + ] + ], *freesurfer_abcd_preproc.outputs, # we're grabbing the postproc outputs and appending them to # the reconall outputs @@ -3059,12 +2963,11 @@ def fnirt_based_brain_extraction(config=None, wf_name="fnirt_based_brain_extract preproc.connect(non_linear_reg, "field_file", apply_warp, "field_file") # Invert warp and transform dilated brain mask back into native space, and use it to mask input image - # Input and reference spaces are the same, using 2mm reference to save time - # invwarp --ref="$Reference2mm" -w "$WD"/str2standard.nii.gz -o "$WD"/standard2str.nii.gz + # invwarp --ref="$T1w" -w "$WD"/str2standard.nii.gz -o "$WD"/standard2str.nii.gz inverse_warp = pe.Node(interface=fsl.InvWarp(), name="inverse_warp") inverse_warp.inputs.output_type = "NIFTI_GZ" - preproc.connect(inputnode, "template_skull_for_anat_2mm", inverse_warp, "reference") + preproc.connect(inputnode, "anat_data", inverse_warp, "reference") preproc.connect(non_linear_reg, "field_file", inverse_warp, "warp") @@ -3167,9 +3070,8 @@ def fast_bias_field_correction(config=None, wf_name="fast_bias_field_correction" @nodeblock( name="correct_restore_brain_intensity_abcd", - config=["anatomical_preproc", "brain_extraction"], - option_key="using", - option_val="FreeSurfer-ABCD", + config=["anatomical_preproc", "restore_t1w_intensity"], + switch=["run"], inputs=[ ( "desc-preproc_T1w", diff --git a/CPAC/anat_preproc/ants.py b/CPAC/anat_preproc/ants.py index cfa771ea55..bb53f099bf 100644 --- a/CPAC/anat_preproc/ants.py +++ b/CPAC/anat_preproc/ants.py @@ -15,6 +15,7 @@ # * Docstrings updated accordingly # * Style modifications # * Removed comments from import blocks +# * Updated to `importlib.resources` from `pkg_resources` # ORIGINAL WORK'S ATTRIBUTION NOTICE: # Copyright 2020 The NiPreps Developers @@ -30,7 +31,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -# Modifications copyright (C) 2019 - 2024 C-PAC Developers +# Modifications copyright (C) 2019 - 2025 C-PAC Developers # This file is part of C-PAC. """Nipype translation of ANTs workflows. @@ -42,10 +43,11 @@ """ from collections import OrderedDict +from importlib.resources import files from logging import getLogger +from typing import Literal from packaging.version import parse as parseversion, Version -from pkg_resources import resource_filename as pkgr_fn from nipype.interfaces import utility as niu from nipype.interfaces.ants import Atropos, MultiplyImages, N4BiasFieldCorrection from nipype.interfaces.fsl.maths import ApplyMask @@ -98,7 +100,7 @@ def init_brain_extraction_wf( # noqa: PLR0913 name="brain_extraction_wf", template_spec=None, use_float=True, - normalization_quality="precise", + normalization_quality: Literal["precise", "testing"] = "precise", omp_nthreads=None, mem_gb=3.0, bids_suffix="T1w", @@ -294,14 +296,14 @@ def init_brain_extraction_wf( # noqa: PLR0913 # Set up spatial normalization settings_file = ( - "antsBrainExtraction_%s.json" + f"antsBrainExtraction_{normalization_quality}.json" if use_laplacian - else "antsBrainExtractionNoLaplacian_%s.json" + else f"antsBrainExtractionNoLaplacian_{normalization_quality}.json" ) norm = pe.Node( Registration( - from_file=pkgr_fn( - "CPAC.anat_preproc", "data/" + settings_file % normalization_quality + from_file=str( + files("CPAC.anat_preproc").joinpath("data").joinpath(settings_file) ) ), name="norm", diff --git a/CPAC/anat_preproc/lesion_preproc.py b/CPAC/anat_preproc/lesion_preproc.py index 07871ae32d..21628c97f0 100644 --- a/CPAC/anat_preproc/lesion_preproc.py +++ b/CPAC/anat_preproc/lesion_preproc.py @@ -58,7 +58,7 @@ def inverse_lesion(lesion_path): return lesion_out -def create_lesion_preproc(wf_name="lesion_preproc"): +def create_lesion_preproc(cfg=None, wf_name="lesion_preproc"): """Process lesions masks. Lesion mask file is deobliqued and reoriented in the same way as the T1 in @@ -133,7 +133,9 @@ def create_lesion_preproc(wf_name="lesion_preproc"): mem_x=(0.0115, "in_file", "t"), ) - lesion_reorient.inputs.orientation = "RPI" + lesion_reorient.inputs.orientation = ( + cfg.pipeline_setup["desired_orientation"] if cfg else "RPI" + ) lesion_reorient.inputs.outputtype = "NIFTI_GZ" preproc.connect(lesion_deoblique, "out_file", lesion_reorient, "in_file") diff --git a/CPAC/anat_preproc/tests/test_anat_preproc.py b/CPAC/anat_preproc/tests/test_anat_preproc.py index 60bc42cead..7a65dd8a3f 100755 --- a/CPAC/anat_preproc/tests/test_anat_preproc.py +++ b/CPAC/anat_preproc/tests/test_anat_preproc.py @@ -1,29 +1,45 @@ +# Copyright (C) 2012-2025 C-PAC Developers + +# This file is part of C-PAC. + +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. + +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . +"""Tests for anatomical preprocessing.""" + import os -from nose.tools import * import numpy as np +import pytest import nibabel as nib -from .. import anat_preproc +from CPAC.anat_preproc import anat_preproc +from CPAC.anat_preproc.anat_preproc import brain_mask_freesurfer +from CPAC.pipeline import nipype_pipeline_engine as pe +from CPAC.pipeline.engine import ResourcePool +from CPAC.utils.configuration import Preconfiguration +from CPAC.utils.test_init import create_dummy_node +CFG = Preconfiguration("ccs-options") -class TestAnatPreproc: - def __init__(self): + +@pytest.mark.skip(reason="This test needs refactoring.") +class TestAnatPreproc: # noqa + def setup_method(self) -> None: """ Initialize and run the the anat_preproc workflow. Populate the node-name : node_output dictionary using the workflow object. This dictionary serves the outputs of each of the nodes in the workflow to all the tests that need them. - - Parameters - ---------- - self - - Returns - ------- - None - - """ self.preproc = anat_preproc.create_anat_preproc() self.input_anat = os.path.abspath("$FSLDIR/data/standard/MNI152_T1_2mm.nii.gz") @@ -269,3 +285,53 @@ def test_anat_brain(self): # print 'correlation: ', correlation assert correlation[0, 1] >= 0.97 + + +@pytest.mark.parametrize("opt", ["FreeSurfer-BET-Loose", "FreeSurfer-BET-Tight"]) +@pytest.mark.parametrize("t1w", ["desc-restore_T1w", "desc-preproc_T1w"]) +def test_brain_mask_freesurfer_fsl(opt: str, t1w: str): + """Test that brain_mask_freesurfer_fsl correctly generates output key using real code.""" + # Create minimal mocks for required workflow/config/strat_pool, but do not patch freesurfer_fsl_brain_connector + + CFG["subject_id"] = opt + + wf = pe.Workflow(name=opt) + pre_resources = [ + t1w, + "space-T1w_desc-brain_mask", + "pipeline-fs_T1", + "pipeline-fs_wmparc", + "pipeline-fs_raw-average", + "pipeline-fs_brainmask", + "freesurfer-subject-dir", + "T1w-brain-template-mask-ccs", + "T1w-ACPC-template", + ] + before_this_test = create_dummy_node("created_before_this_test", pre_resources) + rpool = ResourcePool(name=f"{opt}_{opt}", cfg=CFG) + for resource in pre_resources: + rpool.set_data( + resource, before_this_test, resource, {}, "", before_this_test.name + ) + rpool.gather_pipes(wf, CFG) + strat_pool = next(iter(rpool.get_strats(pre_resources).values())) + + pipe_num = 1 + + result_wf, result_outputs = brain_mask_freesurfer( + wf, CFG, strat_pool, pipe_num, opt + ) + + # The output key should always be present + assert any( + k.startswith("space-T1w_desc-brain_mask") for k in result_outputs + ), "Expected brain_mask key in outputs." + # Should not have loose/tight keys + assert not any( + "loose_brain_mask" in k for k in result_outputs + ), "Loose brain mask key should not be present." + assert not any( + "tight_brain_mask" in k for k in result_outputs + ), "Tight brain mask key should not be present." + # Should return the workflow unchanged + assert result_wf == wf diff --git a/CPAC/anat_preproc/utils.py b/CPAC/anat_preproc/utils.py index 39904bbb66..63a20a4881 100644 --- a/CPAC/anat_preproc/utils.py +++ b/CPAC/anat_preproc/utils.py @@ -487,7 +487,9 @@ def mri_convert(in_file, reslice_like=None, out_file=None, args=None): import os if out_file is None: - out_file = in_file.replace(".mgz", ".nii.gz") + out_file = os.path.join( + os.getcwd(), os.path.basename(in_file).replace(".mgz", ".nii.gz") + ) cmd = "mri_convert %s %s" % (in_file, out_file) @@ -502,6 +504,40 @@ def mri_convert(in_file, reslice_like=None, out_file=None, args=None): return out_file +def mri_convert_reorient(in_file, orientation, out_file=None): + """ + Reorient the mgz files using mri_orient. + + Parameters + ---------- + in_file : string + A path of mgz input file. + orientation : string + Orientation of the output file. + out_file : string + A path of mgz output file. + args : string + Arguments of mri_convert. + + Returns + ------- + out_file : string + A path of reoriented mgz output file. + """ + import os + + if out_file is None: + out_file = os.path.join( + os.getcwd(), os.path.basename(in_file).split(".")[0] + "_reoriented.mgz" + ) + + cmd = "mri_convert %s %s --out_orientation %s" % (in_file, out_file, orientation) + + os.system(cmd) + + return out_file + + def wb_command(in_file): import os diff --git a/CPAC/conftest.py b/CPAC/conftest.py new file mode 100644 index 0000000000..330489ce0d --- /dev/null +++ b/CPAC/conftest.py @@ -0,0 +1,21 @@ +# Copyright (C) 2025 C-PAC Developers + +# This file is part of C-PAC. + +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. + +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . +"""Global fixtures for C-PAC tests.""" + +from CPAC._global_fixtures import bids_examples + +__all__ = ["bids_examples"] diff --git a/CPAC/connectome/connectivity_matrix.py b/CPAC/connectome/connectivity_matrix.py index c0be9f3f27..38c0411e1b 100644 --- a/CPAC/connectome/connectivity_matrix.py +++ b/CPAC/connectome/connectivity_matrix.py @@ -171,7 +171,7 @@ def create_connectome_afni(name, method, pipe_num): imports=["import subprocess"], function=strip_afni_output_header, ), - name="netcorrStripHeader{method}_{pipe_num}", + name=f"netcorrStripHeader{method}_{pipe_num}", ) name_output_node = pe.Node( diff --git a/CPAC/cwas/tests/test_cwas.py b/CPAC/cwas/tests/test_cwas.py index 974fd83513..72abfc4d5a 100755 --- a/CPAC/cwas/tests/test_cwas.py +++ b/CPAC/cwas/tests/test_cwas.py @@ -16,8 +16,6 @@ # License along with C-PAC. If not, see . """Test the CWAS pipeline.""" -from logging import basicConfig, INFO - import pytest import nibabel as nib @@ -25,7 +23,6 @@ from CPAC.utils.monitoring.custom_logging import getLogger logger = getLogger("CPAC.cwas.tests") -basicConfig(format="%(message)s", level=INFO) @pytest.mark.skip(reason="requires RegressionTester") diff --git a/CPAC/cwas/tests/test_mdmr_cython.py b/CPAC/cwas/tests/test_mdmr_cython.py index 16415f9720..cb1f6fea2b 100644 --- a/CPAC/cwas/tests/test_mdmr_cython.py +++ b/CPAC/cwas/tests/test_mdmr_cython.py @@ -1,19 +1,36 @@ -import os +# Copyright (C) 2018-2025 C-PAC Developers -import pytest +# This file is part of C-PAC. +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. -@pytest.mark.skip(reason="possibly deprecated") -def test_mdmr(): - import numpy as np +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. - from CPAC.cwas.cwas import calc_cwas +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . +"""MDMR Cython tests.""" - X = np.genfromtxt(os.path.join(os.path.dirname(__file__), "X.csv"), delimiter=",") - Y = np.genfromtxt(os.path.join(os.path.dirname(__file__), "Y.csv"), delimiter=",") +from importlib.resources import as_file, files - X = X.reshape((X.shape[0], X.shape[1], 1)) +import numpy as np +import pytest + +from CPAC.cwas.cwas import calc_cwas - F_value, p_value = calc_cwas(X, Y, np.array([0, 1, 2], dtype=int), 1000, [0]) +@pytest.mark.skip(reason="possibly deprecated") +def test_mdmr() -> None: + with as_file(files("CPAC").joinpath("cwas/tests")) as _f: + X = np.genfromtxt(_f / "X.csv", delimiter=",") + Y = np.genfromtxt(_f / "Y.csv", delimiter=",") + + X = X.reshape((X.shape[0], X.shape[1], 1)) + + _F_value, p_value = calc_cwas(X, Y, np.array([0, 1, 2], dtype=int), 1000, [0]) assert np.isclose(p_value.mean(), 1.0, rtol=0.1) diff --git a/CPAC/cwas/tests/test_pipeline_cwas.py b/CPAC/cwas/tests/test_pipeline_cwas.py index 866318821a..f910419d2c 100644 --- a/CPAC/cwas/tests/test_pipeline_cwas.py +++ b/CPAC/cwas/tests/test_pipeline_cwas.py @@ -16,7 +16,6 @@ # License along with C-PAC. If not, see . """Test the CWAS pipeline.""" -from logging import basicConfig, INFO import os from urllib.error import URLError @@ -30,7 +29,6 @@ from CPAC.utils.monitoring.custom_logging import getLogger logger = getLogger("CPAC.cwas.tests") -basicConfig(format="%(message)s", level=INFO) @pytest.mark.parametrize("z_score", [[0], [1], [0, 1], []]) diff --git a/CPAC/distortion_correction/distortion_correction.py b/CPAC/distortion_correction/distortion_correction.py index a7f0eaefcc..0ddf005e92 100644 --- a/CPAC/distortion_correction/distortion_correction.py +++ b/CPAC/distortion_correction/distortion_correction.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -# Copyright (C) 2017-2022 C-PAC Developers +# Copyright (C) 2017-2025 C-PAC Developers # This file is part of C-PAC. @@ -16,6 +16,8 @@ # You should have received a copy of the GNU Lesser General Public # License along with C-PAC. If not, see . +"""Distortion correction in C-PAC.""" + import os import subprocess @@ -34,7 +36,7 @@ from CPAC.pipeline import nipype_pipeline_engine as pe from CPAC.pipeline.nodeblock import nodeblock from CPAC.utils import function -from CPAC.utils.datasource import match_epi_fmaps +from CPAC.utils.datasource import match_epi_fmaps_function_node from CPAC.utils.interfaces.function import Function @@ -404,23 +406,7 @@ def distcor_blip_afni_qwarp(wf, cfg, strat_pool, pipe_num, opt=None): 3dQWarp. The output of this can then proceed to func_preproc. """ - match_epi_imports = ["import json"] - match_epi_fmaps_node = pe.Node( - Function( - input_names=[ - "bold_pedir", - "epi_fmap_one", - "epi_fmap_params_one", - "epi_fmap_two", - "epi_fmap_params_two", - ], - output_names=["opposite_pe_epi", "same_pe_epi"], - function=match_epi_fmaps, - imports=match_epi_imports, - as_module=True, - ), - name=f"match_epi_fmaps_{pipe_num}", - ) + match_epi_fmaps_node = match_epi_fmaps_function_node(f"match_epi_fmaps_{pipe_num}") node, out = strat_pool.get_data("epi-1") wf.connect(node, out, match_epi_fmaps_node, "epi_fmap_one") @@ -663,7 +649,7 @@ def distcor_blip_fsl_topup(wf, cfg, strat_pool, pipe_num, opt=None): "import os", "import subprocess", "import numpy as np", - "import nibabel", + "import nibabel as nib", "import sys", ] phase_encoding = pe.Node( diff --git a/CPAC/distortion_correction/utils.py b/CPAC/distortion_correction/utils.py index b76acba074..0635df5eb8 100644 --- a/CPAC/distortion_correction/utils.py +++ b/CPAC/distortion_correction/utils.py @@ -1,4 +1,4 @@ -# Copyright (C) 2021-2023 C-PAC Developers +# Copyright (C) 2021-2025 C-PAC Developers # This file is part of C-PAC. @@ -14,6 +14,8 @@ # You should have received a copy of the GNU Lesser General Public # License along with C-PAC. If not, see . +"""Distortion correction utilities.""" + import os import subprocess import sys diff --git a/CPAC/func_preproc/__init__.py b/CPAC/func_preproc/__init__.py index d10cdc55f0..646c497285 100644 --- a/CPAC/func_preproc/__init__.py +++ b/CPAC/func_preproc/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2012-2023 C-PAC Developers +# Copyright (C) 2012-2025 C-PAC Developers # This file is part of C-PAC. @@ -19,20 +19,20 @@ from .func_motion import ( calc_motion_stats, func_motion_correct, - func_motion_correct_only, func_motion_estimates, - get_motion_ref, + get_motion_refs, motion_estimate_filter, + stack_motion_blocks, ) from .func_preproc import get_idx, slice_timing_wf __all__ = [ "calc_motion_stats", "func_motion_correct", - "func_motion_correct_only", "func_motion_estimates", "get_idx", - "get_motion_ref", + "get_motion_refs", "motion_estimate_filter", "slice_timing_wf", + "stack_motion_blocks", ] diff --git a/CPAC/func_preproc/func_motion.py b/CPAC/func_preproc/func_motion.py index bea7d2e29c..59f76e8141 100644 --- a/CPAC/func_preproc/func_motion.py +++ b/CPAC/func_preproc/func_motion.py @@ -1,4 +1,4 @@ -# Copyright (C) 2012-2024 C-PAC Developers +# Copyright (C) 2012-2025 C-PAC Developers # This file is part of C-PAC. @@ -16,9 +16,11 @@ # License along with C-PAC. If not, see . """Functions for calculating motion parameters.""" -# pylint: disable=ungrouped-imports,wrong-import-order,wrong-import-position +from typing import cast, Literal, TYPE_CHECKING + from nipype.interfaces import afni, fsl, utility as util from nipype.interfaces.afni import preprocess, utils as afni_utils +from nipype.pipeline.engine import Workflow from CPAC.func_preproc.utils import ( chunk_ts, @@ -31,10 +33,19 @@ motion_power_statistics, ) from CPAC.pipeline import nipype_pipeline_engine as pe -from CPAC.pipeline.nodeblock import nodeblock -from CPAC.pipeline.schema import valid_options +from CPAC.pipeline.nodeblock import ( + nodeblock, + NODEBLOCK_RETURN, + NodeBlockFunction, + POOL_RESOURCE_DICT, +) +from CPAC.pipeline.schema import MotionCorrection, valid_options +from CPAC.utils.configuration import Configuration from CPAC.utils.interfaces.function import Function -from CPAC.utils.utils import check_prov_for_motion_tool + +if TYPE_CHECKING: + from CPAC.pipeline.engine import ResourcePool + from CPAC.pipeline.schema import MotionEstimateFilter @nodeblock( @@ -45,6 +56,8 @@ ], inputs=[ ( + "motion-correct-3dvolreg", + "motion-correct-mcflirt", "desc-preproc_bold", "space-bold_desc-brain_mask", "desc-movementParameters_motion", @@ -66,10 +79,15 @@ "desc-summary_motion", ], ) -def calc_motion_stats(wf, cfg, strat_pool, pipe_num, opt=None): +def calc_motion_stats( + wf: Workflow, + cfg: Configuration, + strat_pool: "ResourcePool", + pipe_num: int, + opt: None = None, +) -> NODEBLOCK_RETURN: """Calculate motion statistics for motion parameters.""" - motion_prov = strat_pool.get_cpac_provenance("desc-movementParameters_motion") - motion_correct_tool = check_prov_for_motion_tool(motion_prov) + motion_correct_tool = strat_pool.motion_tool("desc-movementParameters_motion") coordinate_transformation = [ "filtered-coordinate-transformation", "coordinate-transformation", @@ -87,11 +105,20 @@ def calc_motion_stats(wf, cfg, strat_pool, pipe_num, opt=None): gen_motion_stats, "inputspec.motion_correct", ) - wf.connect( - *strat_pool.get_data("space-bold_desc-brain_mask"), - gen_motion_stats, - "inputspec.mask", - ) + if strat_pool.check_rpool("space-bold_desc-brain_mask"): + wf.connect( + *strat_pool.get_data("space-bold_desc-brain_mask"), + gen_motion_stats, + "inputspec.mask", + ) + else: + automask = pe.Node(interface=afni.Automask(), name=f"automask_bold_{pipe_num}") + automask.inputs.dilate = 1 + automask.inputs.outputtype = "NIFTI_GZ" + + node, out = strat_pool.get_data("desc-preproc_bold") + wf.connect(node, out, automask, "in_file") + wf.connect(automask, "out_file", gen_motion_stats, "inputspec.mask") wf.connect( *strat_pool.get_data("desc-movementParameters_motion"), gen_motion_stats, @@ -129,7 +156,7 @@ def calc_motion_stats(wf, cfg, strat_pool, pipe_num, opt=None): return wf, outputs -def estimate_reference_image(in_file): +def estimate_reference_image(in_file: str) -> str: """fMRIPrep-style BOLD reference. Generate a reference 3D map from BOLD and SBRef EPI images for BOLD datasets. @@ -195,7 +222,7 @@ def estimate_reference_image(in_file): # See the License for the specific language governing permissions and # limitations under the License. - # Modifications copyright (C) 2021 - 2024 C-PAC Developers + # Modifications copyright (C) 2021 - 2025 C-PAC Developers import os import numpy as np @@ -244,13 +271,15 @@ def estimate_reference_image(in_file): return out_file -_MOTION_CORRECTED_OUTPUTS = { +_MOTION_CORRECTED_OUTPUTS: dict[str, dict[str, str]] = { "desc-preproc_bold": {"Description": "Motion-corrected BOLD time-series."}, "desc-motion_bold": {"Description": "Motion-corrected BOLD time-series."}, } # the "filtered" outputs here are just for maintaining expecting # forking and connections and will not be output -_MOTION_PARAM_OUTPUTS = { +_MOTION_PARAM_OUTPUTS: dict[str, dict[str, str]] = { + "motion-correct-3dvolreg": {"Description": "Motion correction via 3dVolReg"}, + "motion-correct-mcflirt": {"Description": "Motion correction via MCFLIRT"}, "max-displacement": {}, "rels-displacement": {}, "desc-movementParameters_motion": { @@ -280,37 +309,30 @@ def estimate_reference_image(in_file): "using", ], option_val=["3dvolreg", "mcflirt"], - inputs=[("desc-preproc_bold", "motion-basefile")], - outputs={**_MOTION_CORRECTED_OUTPUTS, **_MOTION_PARAM_OUTPUTS}, -) -def func_motion_correct(wf, cfg, strat_pool, pipe_num, opt=None): - wf, outputs = motion_correct_connections(wf, cfg, strat_pool, pipe_num, opt) - - return wf, outputs - - -@nodeblock( - name="motion_correction_only", - switch=["functional_preproc", "motion_estimates_and_correction", "run"], - option_key=[ - "functional_preproc", - "motion_estimates_and_correction", - "motion_correction", - "using", + inputs=[ + ( + "motion-correct-3dvolreg", + "motion-correct-mcflirt", + "desc-preproc_bold", + "motion-basefile", + ) ], - option_val=["3dvolreg", "mcflirt"], - inputs=[("desc-preproc_bold", "motion-basefile")], - outputs=_MOTION_CORRECTED_OUTPUTS, + outputs={ + **_MOTION_CORRECTED_OUTPUTS, + **_MOTION_PARAM_OUTPUTS, + }, ) -def func_motion_correct_only(wf, cfg, strat_pool, pipe_num, opt=None): - wf, wf_outputs = motion_correct_connections(wf, cfg, strat_pool, pipe_num, opt) - - outputs = { - "desc-preproc_bold": wf_outputs["desc-motion_bold"], - "desc-motion_bold": wf_outputs["desc-motion_bold"], - } - - return (wf, outputs) +def func_motion_correct( + wf: Workflow, + cfg: Configuration, + strat_pool: "ResourcePool", + pipe_num: int, + opt: MotionCorrection, +) -> NODEBLOCK_RETURN: + """Perform motion estimation and correction using 3dVolReg or MCFLIRT.""" + return motion_correct_connections( + wf, cfg, strat_pool, pipe_num, opt, estimate=False, correct=True + ) @nodeblock( @@ -323,30 +345,33 @@ def func_motion_correct_only(wf, cfg, strat_pool, pipe_num, opt=None): "using", ], option_val=["3dvolreg", "mcflirt"], - inputs=[("desc-preproc_bold", "motion-basefile")], + inputs=[ + ( + "desc-preproc_bold", + "motion-basefile", + "motion-correct-3dvolreg", + "motion-correct-mcflirt", + "space-bold_desc-brain_mask", + ) + ], outputs=_MOTION_PARAM_OUTPUTS, ) -def func_motion_estimates(wf, cfg, strat_pool, pipe_num, opt=None): +def func_motion_estimates( + wf: Workflow, + cfg: Configuration, + strat_pool: "ResourcePool", + pipe_num: int, + opt: MotionCorrection, +) -> NODEBLOCK_RETURN: """Calculate motion estimates using 3dVolReg or MCFLIRT.""" - from CPAC.pipeline.utils import present_outputs - - wf, wf_outputs = motion_correct_connections(wf, cfg, strat_pool, pipe_num, opt) - return ( - wf, - present_outputs( - wf_outputs, - [ - "coordinate-transformation", - "filtered-coordinate-transformation", - "max-displacement", - "desc-movementParameters_motion", - "rels-displacement", - ], - ), + return motion_correct_connections( + wf, cfg, strat_pool, pipe_num, opt, estimate=True, correct=False ) -def get_mcflirt_rms_abs(rms_files): +def get_mcflirt_rms_abs(rms_files: list[str]) -> tuple[str, str]: + """Split the RMS files into absolute and relative.""" + abs_file = rels_file = "not found" for path in rms_files: if "abs.rms" in path: abs_file = path @@ -364,361 +389,443 @@ def get_mcflirt_rms_abs(rms_files): "motion_correction", "motion_correction_reference", ], - option_val=["mean", "median", "selected_volume", "fmriprep_reference"], - inputs=["desc-preproc_bold", "desc-reorient_bold"], + option_val=["mean", "median", "selected_volume"], + inputs=["desc-preproc_bold"], outputs=["motion-basefile"], ) -def get_motion_ref(wf, cfg, strat_pool, pipe_num, opt=None): - if opt not in get_motion_ref.option_val: - msg = ( - "\n\n[!] Error: The 'motion_correction_reference' " - "parameter of the 'motion_correction' workflow " - "must be one of:\n\t{0}.\n\nTool input: '{1}'" - "\n\n".format( - " or ".join([f"'{val}'" for val in get_motion_ref.option_val]), opt +def get_motion_ref( + wf: Workflow, + cfg: Configuration, + strat_pool: "ResourcePool", + pipe_num: int, + opt: Literal["mean", "median", "selected_volume"], +) -> NODEBLOCK_RETURN: + """Get the reference image for motion correction.""" + in_label = "in_file" + match opt: + case "mean": + func_get_RPI = pe.Node( + interface=afni_utils.TStat(options="-mean"), + name=f"func_get_mean_RPI_{pipe_num}", + mem_gb=0.48, + mem_x=(1435097126797993 / 302231454903657293676544, in_label), ) - ) - raise ValueError(msg) - - if opt == "mean": - func_get_RPI = pe.Node( - interface=afni_utils.TStat(), - name=f"func_get_mean_RPI_{pipe_num}", - mem_gb=0.48, - mem_x=(1435097126797993 / 302231454903657293676544, "in_file"), - ) - - func_get_RPI.inputs.options = "-mean" - func_get_RPI.inputs.outputtype = "NIFTI_GZ" - - node, out = strat_pool.get_data("desc-preproc_bold") - wf.connect(node, out, func_get_RPI, "in_file") - - elif opt == "median": - func_get_RPI = pe.Node( - interface=afni_utils.TStat(), name=f"func_get_median_RPI_{pipe_num}" - ) + case "median": + func_get_RPI = pe.Node( + interface=afni_utils.TStat(options="-median"), + name=f"func_get_median_RPI_{pipe_num}", + ) + case "selected_volume": + func_get_RPI = pe.Node( + interface=afni.Calc( + expr="a", + single_idx=cfg.functional_preproc[ + "motion_estimates_and_correction" + ]["motion_correction"]["motion_correction_reference_volume"], + ), + name=f"func_get_selected_RPI_{pipe_num}", + ) + in_label = "in_file_a" + case _: + msg = ( + "\n\n[!] Error: The 'motion_correction_reference' " + "parameter of the 'motion_correction' workflow " + "must be one of:\n\t{0}.\n\nTool input: '{1}'" + "\n\n".format( + " or ".join([f"'{val}'" for val in get_motion_ref.option_val]), opt + ) + ) + raise ValueError(msg) + node, out = strat_pool.get_data("desc-preproc_bold") + func_get_RPI.inputs.outputtype = "NIFTI_GZ" + wf.connect(node, out, func_get_RPI, in_label) + outputs = {"motion-basefile": (func_get_RPI, "out_file")} + return wf, outputs - func_get_RPI.inputs.options = "-median" - func_get_RPI.inputs.outputtype = "NIFTI_GZ" - node, out = strat_pool.get_data("desc-preproc_bold") - wf.connect(node, out, func_get_RPI, "in_file") +@nodeblock( + name="get_motion_ref_fmriprep", + switch=["functional_preproc", "motion_estimates_and_correction", "run"], + option_key=[ + "functional_preproc", + "motion_estimates_and_correction", + "motion_correction", + "motion_correction_reference", + ], + option_val=["fmriprep_reference"], + inputs=["desc-reorient_bold"], + outputs=["motion-basefile"], +) +def get_motion_ref_fmriprep( + wf: Workflow, + cfg: Configuration, + strat_pool: "ResourcePool", + pipe_num: int, + opt: Literal["fmriprep_reference"], +) -> NODEBLOCK_RETURN: + """Get the fMRIPrep-style reference image for motion correction.""" + assert opt == "fmriprep_reference" + func_get_RPI = pe.Node( + Function( + input_names=["in_file"], + output_names=["out_file"], + function=estimate_reference_image, + ), + name=f"func_get_fmriprep_ref_{pipe_num}", + ) - elif opt == "selected_volume": - func_get_RPI = pe.Node( - interface=afni.Calc(), name=f"func_get_selected_RPI_{pipe_num}" - ) + node, out = strat_pool.get_data("desc-reorient_bold") + wf.connect(node, out, func_get_RPI, "in_file") - func_get_RPI.inputs.set( - expr="a", - single_idx=cfg.functional_preproc["motion_estimates_and_correction"][ - "motion_correction" - ]["motion_correction_reference_volume"], - outputtype="NIFTI_GZ", - ) + outputs = {"motion-basefile": (func_get_RPI, "out_file")} - node, out = strat_pool.get_data("desc-preproc_bold") - wf.connect(node, out, func_get_RPI, "in_file_a") + return wf, outputs - elif opt == "fmriprep_reference": - func_get_RPI = pe.Node( - Function( - input_names=["in_file"], - output_names=["out_file"], - function=estimate_reference_image, - ), - name=f"func_get_fmriprep_ref_{pipe_num}", - ) - node, out = strat_pool.get_data("desc-reorient_bold") - wf.connect(node, out, func_get_RPI, "in_file") +get_motion_refs = [get_motion_ref, get_motion_ref_fmriprep] - outputs = {"motion-basefile": (func_get_RPI, "out_file")} - return (wf, outputs) +def _pipe_suffix(estimate: bool, correct: bool, pipe_num: int) -> str: + """Generate a suffix for the pipeline name based on estimate and correct flags.""" + suffix = "" + if estimate: + suffix += "-estimate" + if correct: + suffix += "-correct" + return f"{suffix}_{pipe_num}" -def motion_correct_3dvolreg(wf, cfg, strat_pool, pipe_num): +def motion_correct_3dvolreg( + wf: Workflow, + cfg: Configuration, + strat_pool: "ResourcePool", + pipe_num: int, + estimate: bool, + correct: bool, +) -> NODEBLOCK_RETURN: """Calculate motion parameters with 3dvolreg.""" - if int(cfg.pipeline_setup["system_config"]["max_cores_per_participant"]) > 1: - chunk_imports = ["import nibabel as nib"] - chunk = pe.Node( - Function( - input_names=["func_file", "n_chunks", "chunk_size"], - output_names=["TR_ranges"], - function=chunk_ts, - imports=chunk_imports, - ), - name=f"chunk_{pipe_num}", - ) + outputs: POOL_RESOURCE_DICT = {} + pipe_suffix = _pipe_suffix(estimate, correct, pipe_num) + if strat_pool.check_rpool("motion-correct-3dvolreg"): + out_motion_A, _ = strat_pool.get_data("motion-correct-3dvolreg") + else: + if int(cfg.pipeline_setup["system_config"]["max_cores_per_participant"]) > 1: + chunk_imports = ["import nibabel as nib"] + chunk = pe.Node( + Function( + input_names=["func_file", "n_chunks", "chunk_size"], + output_names=["TR_ranges"], + function=chunk_ts, + imports=chunk_imports, + ), + name=f"chunk{pipe_suffix}", + ) - # chunk.inputs.n_chunks = int(cfg.pipeline_setup['system_config'][ - # 'max_cores_per_participant']) + # chunk.inputs.n_chunks = int(cfg.pipeline_setup['system_config'][ + # 'max_cores_per_participant']) - # 10-TR sized chunks - chunk.inputs.chunk_size = 10 + # 10-TR sized chunks + chunk.inputs.chunk_size = 10 - node, out = strat_pool.get_data("desc-preproc_bold") - wf.connect(node, out, chunk, "func_file") + node, out = strat_pool.get_data("desc-preproc_bold") + wf.connect(node, out, chunk, "func_file") - split_imports = ["import os", "import subprocess"] - split = pe.Node( - Function( - input_names=["func_file", "tr_ranges"], - output_names=["split_funcs"], - function=split_ts_chunks, - imports=split_imports, - ), - name=f"split_{pipe_num}", - ) + split_imports = ["import os", "import subprocess"] + split = pe.Node( + Function( + input_names=["func_file", "tr_ranges"], + output_names=["split_funcs"], + function=split_ts_chunks, + imports=split_imports, + ), + name=f"split{pipe_suffix}", + ) - node, out = strat_pool.get_data("desc-preproc_bold") - wf.connect(node, out, split, "func_file") - wf.connect(chunk, "TR_ranges", split, "tr_ranges") + node, out = strat_pool.get_data("desc-preproc_bold") + wf.connect(node, out, split, "func_file") + wf.connect(chunk, "TR_ranges", split, "tr_ranges") - out_split_func = pe.Node( - interface=util.IdentityInterface(fields=["out_file"]), - name=f"out_split_func_{pipe_num}", - ) - - wf.connect(split, "split_funcs", out_split_func, "out_file") + out_split_func = pe.Node( + interface=util.IdentityInterface(fields=["out_file"]), + name=f"out_split_func{pipe_suffix}", + ) - func_motion_correct = pe.MapNode( - interface=preprocess.Volreg(), - name=f"func_generate_ref_{pipe_num}", - iterfield=["in_file"], - ) + wf.connect(split, "split_funcs", out_split_func, "out_file") - wf.connect(out_split_func, "out_file", func_motion_correct, "in_file") + func_motion_correct = pe.MapNode( + interface=preprocess.Volreg(), + name=f"func_generate_ref{pipe_suffix}", + iterfield=["in_file"], + ) - func_concat = pe.Node( - interface=afni_utils.TCat(), name=f"func_concat_{pipe_num}" - ) - func_concat.inputs.outputtype = "NIFTI_GZ" + wf.connect(out_split_func, "out_file", func_motion_correct, "in_file") - wf.connect(func_motion_correct, "out_file", func_concat, "in_files") + func_concat = pe.Node( + interface=afni_utils.TCat(), name=f"func_concat{pipe_suffix}" + ) + func_concat.inputs.outputtype = "NIFTI_GZ" - out_motion = pe.Node( - interface=util.IdentityInterface(fields=["out_file"]), - name=f"out_motion_{pipe_num}", - ) + wf.connect(func_motion_correct, "out_file", func_concat, "in_files") - wf.connect(func_concat, "out_file", out_motion, "out_file") + out_motion = pe.Node( + interface=util.IdentityInterface(fields=["out_file"]), + name=f"out_motion{pipe_suffix}", + ) - else: - out_split_func = pe.Node( - interface=util.IdentityInterface(fields=["out_file"]), - name=f"out_split_func_{pipe_num}", - ) + wf.connect(func_concat, "out_file", out_motion, "out_file") - node, out = strat_pool.get_data("desc-preproc_bold") - wf.connect(node, out, out_split_func, "out_file") + else: + out_split_func = pe.Node( + interface=util.IdentityInterface(fields=["out_file"]), + name=f"out_split_func{pipe_suffix}", + ) - func_motion_correct = pe.Node( - interface=preprocess.Volreg(), name=f"func_generate_ref_{pipe_num}" - ) + node, out = strat_pool.get_data("desc-preproc_bold") + wf.connect(node, out, out_split_func, "out_file") - wf.connect(out_split_func, "out_file", func_motion_correct, "in_file") + func_motion_correct = pe.Node( + interface=preprocess.Volreg(), name=f"func_generate_ref{pipe_suffix}" + ) - out_motion = pe.Node( - interface=util.IdentityInterface(fields=["out_file"]), - name=f"out_motion_{pipe_num}", - ) + wf.connect(out_split_func, "out_file", func_motion_correct, "in_file") - wf.connect(func_motion_correct, "out_file", out_motion, "out_file") + out_motion = pe.Node( + interface=util.IdentityInterface(fields=["out_file"]), + name=f"out_motion{pipe_suffix}", + ) - func_motion_correct.inputs.zpad = 4 - func_motion_correct.inputs.outputtype = "NIFTI_GZ" + wf.connect(func_motion_correct, "out_file", out_motion, "out_file") - args = "-Fourier" - if cfg.functional_preproc["motion_estimates_and_correction"]["motion_correction"][ - "AFNI-3dvolreg" - ]["functional_volreg_twopass"]: - args = f"-twopass {args}" + func_motion_correct.inputs.zpad = 4 + func_motion_correct.inputs.outputtype = "NIFTI_GZ" - func_motion_correct.inputs.args = args + args = "-Fourier" + if cfg.functional_preproc["motion_estimates_and_correction"][ + "motion_correction" + ]["AFNI-3dvolreg"]["functional_volreg_twopass"]: + args = f"-twopass {args}" - # Calculate motion parameters - func_motion_correct_A = func_motion_correct.clone( - f"func_motion_correct_3dvolreg_{pipe_num}" - ) - func_motion_correct_A.inputs.md1d_file = "max_displacement.1D" - func_motion_correct_A.inputs.args = args + func_motion_correct.inputs.args = args - wf.connect(out_split_func, "out_file", func_motion_correct_A, "in_file") + # Calculate motion parameters + func_motion_correct_A = func_motion_correct.clone( + f"func_motion_correct_3dvolreg{pipe_suffix}" + ) + func_motion_correct_A.inputs.md1d_file = "max_displacement.1D" + func_motion_correct_A.inputs.args = args - node, out = strat_pool.get_data("motion-basefile") - wf.connect(node, out, func_motion_correct_A, "basefile") + wf.connect(out_split_func, "out_file", func_motion_correct_A, "in_file") - if int(cfg.pipeline_setup["system_config"]["max_cores_per_participant"]) > 1: - motion_concat = pe.Node( - interface=afni_utils.TCat(), name=f"motion_concat_{pipe_num}" - ) - motion_concat.inputs.outputtype = "NIFTI_GZ" + node, out = strat_pool.get_data("motion-basefile") + wf.connect(node, out, func_motion_correct_A, "basefile") - wf.connect(func_motion_correct_A, "out_file", motion_concat, "in_files") + if int(cfg.pipeline_setup["system_config"]["max_cores_per_participant"]) > 1: + motion_concat = pe.Node( + interface=afni_utils.TCat(), name=f"motion_concat{pipe_suffix}" + ) + motion_concat.inputs.outputtype = "NIFTI_GZ" - out_motion_A = pe.Node( - interface=util.IdentityInterface(fields=["out_file"]), - name=f"out_motion_A_{pipe_num}", - ) + wf.connect(func_motion_correct_A, "out_file", motion_concat, "in_files") - wf.connect(motion_concat, "out_file", out_motion_A, "out_file") + out_motion_A = pe.Node( + interface=util.IdentityInterface(fields=["out_file"]), + name=f"out_motion_A{pipe_suffix}", + ) - concat_imports = ["import os"] - md1d_concat = pe.Node( - Function( - input_names=["in_files"], - output_names=["out_file"], - function=oned_text_concat, - imports=concat_imports, - ), - name=f"md1d_concat_{pipe_num}", - ) + wf.connect(motion_concat, "out_file", out_motion_A, "out_file") + + concat_imports = ["import os"] + md1d_concat = pe.Node( + Function( + input_names=["in_files"], + output_names=["out_file"], + function=oned_text_concat, + imports=concat_imports, + ), + name=f"md1d_concat{pipe_suffix}", + ) - wf.connect(func_motion_correct_A, "md1d_file", md1d_concat, "in_files") + wf.connect(func_motion_correct_A, "md1d_file", md1d_concat, "in_files") - oned_concat = pe.Node( - Function( - input_names=["in_files"], - output_names=["out_file"], - function=oned_text_concat, - imports=concat_imports, - ), - name=f"oned_concat_{pipe_num}", - ) + oned_concat = pe.Node( + Function( + input_names=["in_files"], + output_names=["out_file"], + function=oned_text_concat, + imports=concat_imports, + ), + name=f"oned_concat{pipe_suffix}", + ) - wf.connect(func_motion_correct_A, "oned_file", oned_concat, "in_files") + wf.connect(func_motion_correct_A, "oned_file", oned_concat, "in_files") - oned_matrix_concat = pe.Node( - Function( - input_names=["in_files"], - output_names=["out_file"], - function=oned_text_concat, - imports=concat_imports, - ), - name=f"oned_matrix_concat_{pipe_num}", - ) + oned_matrix_concat = pe.Node( + Function( + input_names=["in_files"], + output_names=["out_file"], + function=oned_text_concat, + imports=concat_imports, + ), + name=f"oned_matrix_concat{pipe_suffix}", + ) - wf.connect( - func_motion_correct_A, "oned_matrix_save", oned_matrix_concat, "in_files" - ) + wf.connect( + func_motion_correct_A, + "oned_matrix_save", + oned_matrix_concat, + "in_files", + ) - out_md1d = pe.Node( - interface=util.IdentityInterface(fields=["out_file"]), - name=f"out_md1d_{pipe_num}", - ) + out_md1d = pe.Node( + interface=util.IdentityInterface(fields=["out_file"]), + name=f"out_md1d{pipe_suffix}", + ) - wf.connect(md1d_concat, "out_file", out_md1d, "out_file") + wf.connect(md1d_concat, "out_file", out_md1d, "out_file") - out_oned = pe.Node( - interface=util.IdentityInterface(fields=["out_file"]), - name=f"out_oned_{pipe_num}", - ) + out_oned = pe.Node( + interface=util.IdentityInterface(fields=["out_file"]), + name=f"out_oned{pipe_suffix}", + ) - wf.connect(oned_concat, "out_file", out_oned, "out_file") + wf.connect(oned_concat, "out_file", out_oned, "out_file") - out_oned_matrix = pe.Node( - interface=util.IdentityInterface(fields=["out_file"]), - name=f"out_oned_matrix_{pipe_num}", - ) + out_oned_matrix = pe.Node( + interface=util.IdentityInterface(fields=["out_file"]), + name=f"out_oned_matrix{pipe_suffix}", + ) - wf.connect(oned_matrix_concat, "out_file", out_oned_matrix, "out_file") + wf.connect(oned_matrix_concat, "out_file", out_oned_matrix, "out_file") - else: - out_motion_A = pe.Node( - interface=util.IdentityInterface(fields=["out_file"]), - name=f"out_motion_A_{pipe_num}", - ) + else: + out_motion_A = pe.Node( + interface=util.IdentityInterface(fields=["out_file"]), + name=f"out_motion_A{pipe_suffix}", + ) - wf.connect(func_motion_correct_A, "out_file", out_motion_A, "out_file") + wf.connect(func_motion_correct_A, "out_file", out_motion_A, "out_file") - out_md1d = pe.Node( - interface=util.IdentityInterface(fields=["out_file"]), - name=f"out_md1d_{pipe_num}", - ) + out_md1d = pe.Node( + interface=util.IdentityInterface(fields=["out_file"]), + name=f"out_md1d{pipe_suffix}", + ) - wf.connect(func_motion_correct_A, "md1d_file", out_md1d, "out_file") + wf.connect(func_motion_correct_A, "md1d_file", out_md1d, "out_file") - out_oned = pe.Node( - interface=util.IdentityInterface(fields=["out_file"]), - name=f"out_oned_{pipe_num}", - ) + out_oned = pe.Node( + interface=util.IdentityInterface(fields=["out_file"]), + name=f"out_oned{pipe_suffix}", + ) - wf.connect(func_motion_correct_A, "oned_file", out_oned, "out_file") + wf.connect(func_motion_correct_A, "oned_file", out_oned, "out_file") - out_oned_matrix = pe.Node( - interface=util.IdentityInterface(fields=["out_file"]), - name=f"out_oned_matrix_{pipe_num}", - ) + out_oned_matrix = pe.Node( + interface=util.IdentityInterface(fields=["out_file"]), + name=f"out_oned_matrix{pipe_suffix}", + ) - wf.connect( - func_motion_correct_A, "oned_matrix_save", out_oned_matrix, "out_file" + wf.connect( + func_motion_correct_A, "oned_matrix_save", out_oned_matrix, "out_file" + ) + if estimate: + outputs.update( + { + "max-displacement": (out_md1d, "out_file"), + "desc-movementParameters_motion": (out_oned, "out_file"), + "coordinate-transformation": (out_oned_matrix, "out_file"), + "filtered-coordinate-transformation": (out_oned_matrix, "out_file"), + } + ) + if correct: + outputs.update( + { + "motion-correct-3dvolreg": (out_motion_A, ""), + "desc-preproc_bold": (out_motion_A, "out_file"), + "desc-motion_bold": (out_motion_A, "out_file"), + } ) - outputs = { - "desc-preproc_bold": (out_motion_A, "out_file"), - "desc-motion_bold": (out_motion_A, "out_file"), - "max-displacement": (out_md1d, "out_file"), - "desc-movementParameters_motion": (out_oned, "out_file"), - "coordinate-transformation": (out_oned_matrix, "out_file"), - "filtered-coordinate-transformation": (out_oned_matrix, "out_file"), - } - return wf, outputs -def motion_correct_mcflirt(wf, cfg, strat_pool, pipe_num): +def motion_correct_mcflirt( + wf: Workflow, + cfg: Configuration, + strat_pool: "ResourcePool", + pipe_num: int, + estimate: bool, + correct: bool, +) -> NODEBLOCK_RETURN: """Calculate motion parameters with MCFLIRT.""" - func_motion_correct_A = pe.Node( - interface=fsl.MCFLIRT(save_mats=True, save_plots=True), - name=f"func_motion_correct_mcflirt_{pipe_num}", - mem_gb=2.5, - ) - - func_motion_correct_A.inputs.save_mats = True - func_motion_correct_A.inputs.save_plots = True - func_motion_correct_A.inputs.save_rms = True - - node, out = strat_pool.get_data("desc-preproc_bold") - wf.connect(node, out, func_motion_correct_A, "in_file") + pipe_suffix = _pipe_suffix(estimate, correct, pipe_num) + outputs: POOL_RESOURCE_DICT = {} + if strat_pool.check_rpool("motion-correct-mcflirt"): + func_motion_correct_A, _ = strat_pool.get_data("motion-correct-mcflirt") + else: + func_motion_correct_A = pe.Node( + interface=fsl.MCFLIRT(save_mats=True, save_plots=True), + name=f"func_motion_correct_mcflirt{pipe_suffix}", + mem_gb=2.5, + ) - node, out = strat_pool.get_data("motion-basefile") - wf.connect(node, out, func_motion_correct_A, "ref_file") + func_motion_correct_A.inputs.save_mats = True + func_motion_correct_A.inputs.save_plots = True + func_motion_correct_A.inputs.save_rms = True - normalize_motion_params = pe.Node( - Function( - input_names=["in_file"], - output_names=["out_file"], - function=normalize_motion_parameters, - ), - name=f"norm_motion_params_{pipe_num}", - ) + node, out = strat_pool.get_data("desc-preproc_bold") + wf.connect(node, out, func_motion_correct_A, "in_file") - wf.connect(func_motion_correct_A, "par_file", normalize_motion_params, "in_file") + node, out = strat_pool.get_data("motion-basefile") + wf.connect(node, out, func_motion_correct_A, "ref_file") - get_rms_abs = pe.Node( - Function( - input_names=["rms_files"], - output_names=["abs_file", "rels_file"], - function=get_mcflirt_rms_abs, - ), - name=f"get_mcflirt_rms_abs_{pipe_num}", - ) + normalize_motion_params = pe.Node( + Function( + input_names=["in_file"], + output_names=["out_file"], + function=normalize_motion_parameters, + ), + name=f"norm_motion_params{pipe_suffix}", + ) - wf.connect(func_motion_correct_A, "rms_files", get_rms_abs, "rms_files") + wf.connect( + func_motion_correct_A, "par_file", normalize_motion_params, "in_file" + ) - outputs = { - "desc-preproc_bold": (func_motion_correct_A, "out_file"), - "desc-motion_bold": (func_motion_correct_A, "out_file"), - "max-displacement": (get_rms_abs, "abs_file"), - "rels-displacement": (get_rms_abs, "rels_file"), - "desc-movementParameters_motion": (normalize_motion_params, "out_file"), - "coordinate-transformation": (func_motion_correct_A, "mat_file"), - "filtered-coordinate-transformation": (func_motion_correct_A, "mat_file"), - } + get_rms_abs = pe.Node( + Function( + input_names=["rms_files"], + output_names=["abs_file", "rels_file"], + function=get_mcflirt_rms_abs, + ), + name=f"get_mcflirt_rms_abs{pipe_suffix}", + ) + wf.connect(func_motion_correct_A, "rms_files", get_rms_abs, "rms_files") + + if estimate: + outputs.update( + { + "max-displacement": (get_rms_abs, "abs_file"), + "rels-displacement": (get_rms_abs, "rels_file"), + "desc-movementParameters_motion": ( + normalize_motion_params, + "out_file", + ), + "coordinate-transformation": (func_motion_correct_A, "mat_file"), + "filtered-coordinate-transformation": ( + func_motion_correct_A, + "mat_file", + ), + } + ) + if correct: + outputs.update( + { + "motion-correct-mcflirt": (func_motion_correct_A, ""), + "desc-preproc_bold": (func_motion_correct_A, "out_file"), + "desc-motion_bold": (func_motion_correct_A, "out_file"), + } + ) return wf, outputs @@ -728,9 +835,19 @@ def motion_correct_mcflirt(wf, cfg, strat_pool, pipe_num): } -def motion_correct_connections(wf, cfg, strat_pool, pipe_num, opt): +def motion_correct_connections( + wf: Workflow, + cfg: Configuration, + strat_pool: "ResourcePool", + pipe_num: int, + opt: MotionCorrection, + estimate: bool, + correct: bool, +) -> NODEBLOCK_RETURN: """Check opt for valid option, then connect that option.""" - motion_correct_options = valid_options["motion_correction"] + motion_correct_options = cast( + list[MotionCorrection], valid_options["motion_correction"] + ) if opt not in motion_correct_options: msg = ( "\n\n[!] Error: The 'tool' parameter of the " @@ -739,7 +856,7 @@ def motion_correct_connections(wf, cfg, strat_pool, pipe_num, opt): f".\n\nTool input: {opt}\n\n" ) raise KeyError(msg) - return motion_correct[opt](wf, cfg, strat_pool, pipe_num) + return motion_correct[opt](wf, cfg, strat_pool, pipe_num, estimate, correct) @nodeblock( @@ -759,6 +876,7 @@ def motion_correct_connections(wf, cfg, strat_pool, pipe_num, opt): "max-displacement", "rels-displacement", "coordinate-transformation", + "filtered-coordinate-transformation", "desc-movementParameters_motion", ), "TR", @@ -786,7 +904,13 @@ def motion_correct_connections(wf, cfg, strat_pool, pipe_num, opt): "motion-filter-plot": {}, }, ) -def motion_estimate_filter(wf, cfg, strat_pool, pipe_num, opt=None): +def motion_estimate_filter( + wf: Workflow, + cfg: Configuration, + strat_pool: "ResourcePool", + pipe_num: int, + opt: "MotionEstimateFilter", +) -> NODEBLOCK_RETURN: """Filter motion parameters. .. versionchanged:: 1.8.6 @@ -868,10 +992,10 @@ def motion_estimate_filter(wf, cfg, strat_pool, pipe_num, opt=None): movement_parameters.out, ) - return (wf, outputs) + return wf, outputs -def normalize_motion_parameters(in_file): +def normalize_motion_parameters(in_file: str) -> str: """Convert FSL mcflirt motion parameters to AFNI space.""" import os @@ -894,3 +1018,27 @@ def normalize_motion_parameters(in_file): np.savetxt(out_file, motion_params) return out_file + + +def stack_motion_blocks( + func_blocks: dict[str, list[NodeBlockFunction | list[NodeBlockFunction]]], + cfg: Configuration, + rpool: "ResourcePool", +) -> list[NodeBlockFunction | list[NodeBlockFunction]]: + """Create a stack of motion correction nodeblocks.""" + func_blocks["motion"] = [] + if not rpool.check_rpool("motion-basefile"): + func_blocks["motion"].extend(get_motion_refs) + assert calc_motion_stats.inputs + if not all(rpool.check_rpool(resource) for resource in calc_motion_stats.inputs): + func_blocks["motion"].append(func_motion_estimates) + func_blocks["motion"].append(motion_estimate_filter) + return [ + *func_blocks["init"], + *func_blocks["motion"], + *func_blocks["preproc"], + func_motion_correct, + *func_blocks["mask"], + calc_motion_stats, + *func_blocks["prep"], + ] diff --git a/CPAC/func_preproc/func_preproc.py b/CPAC/func_preproc/func_preproc.py index 7004b4f025..10a70392cb 100644 --- a/CPAC/func_preproc/func_preproc.py +++ b/CPAC/func_preproc/func_preproc.py @@ -1,4 +1,4 @@ -# Copyright (C) 2012-2023 C-PAC Developers +# Copyright (C) 2012-2025 C-PAC Developers # This file is part of C-PAC. @@ -16,20 +16,26 @@ # License along with C-PAC. If not, see . """Functional preprocessing.""" +from typing import TYPE_CHECKING + # pylint: disable=ungrouped-imports,wrong-import-order,wrong-import-position from nipype.interfaces import afni, ants, fsl, utility as util from nipype.interfaces.afni import preprocess, utils as afni_utils -from CPAC.func_preproc.utils import nullify +from CPAC.func_preproc.utils import get_num_slices, interpolate_slice_timing, nullify from CPAC.pipeline import nipype_pipeline_engine as pe -from CPAC.pipeline.nodeblock import nodeblock +from CPAC.pipeline.nodeblock import nodeblock, NODEBLOCK_RETURN, POOL_RESOURCE_DICT from CPAC.utils.interfaces import Function from CPAC.utils.interfaces.ants import ( AI, # niworkflows PrintHeader, SetDirectionByMatrix, ) -from CPAC.utils.utils import add_afni_prefix +from CPAC.utils.utils import add_afni_prefix, afni_3dwarp + +if TYPE_CHECKING: + from CPAC.pipeline.engine import ResourcePool + from CPAC.utils.configuration import Configuration def collect_arguments(*args): @@ -501,42 +507,284 @@ def get_idx(in_files, stop_idx=None, start_idx=None): return stopidx, startidx +def fsl_afni_subworkflow(cfg, pipe_num, opt=None): + wf = pe.Workflow(name=f"fsl_afni_subworkflow_{pipe_num}") + + inputNode = pe.Node( + util.IdentityInterface( + fields=[ + "FSL-AFNI-bold-ref", + "FSL-AFNI-brain-mask", + "FSL-AFNI-brain-probseg", + "motion-basefile", + ] + ), + name="inputspec", + ) + + outputNode = pe.Node( + util.IdentityInterface( + fields=["space-bold_desc-brain_mask", "desc-unifized_bold"] + ), + name="outputspec", + ) + + # Initialize transforms with antsAI + init_aff = pe.Node( + AI( + metric=("Mattes", 32, "Regular", 0.2), + transform=("Affine", 0.1), + search_factor=(20, 0.12), + principal_axes=False, + convergence=(10, 1e-6, 10), + verbose=True, + ), + name=f"init_aff_{pipe_num}", + n_procs=cfg.pipeline_setup["system_config"]["num_OMP_threads"], + ) + + init_aff.inputs.search_grid = (40, (0, 40, 40)) + + # Set up spatial normalization + norm = pe.Node( + ants.Registration( + winsorize_upper_quantile=0.98, + winsorize_lower_quantile=0.05, + float=True, + metric=["Mattes"], + metric_weight=[1], + radius_or_number_of_bins=[64], + transforms=["Affine"], + transform_parameters=[[0.1]], + number_of_iterations=[[200]], + convergence_window_size=[10], + convergence_threshold=[1.0e-9], + sampling_strategy=["Random", "Random"], + smoothing_sigmas=[[2]], + sigma_units=["mm", "mm", "mm"], + shrink_factors=[[2]], + sampling_percentage=[0.2], + use_histogram_matching=[True], + ), + name=f"norm_{pipe_num}", + n_procs=cfg.pipeline_setup["system_config"]["num_OMP_threads"], + ) + + map_brainmask = pe.Node( + ants.ApplyTransforms( + interpolation="BSpline", + float=True, + ), + name=f"map_brainmask_{pipe_num}", + ) + + binarize_mask = pe.Node( + interface=fsl.maths.MathsCommand(), name=f"binarize_mask_{pipe_num}" + ) + binarize_mask.inputs.args = "-thr 0.85 -bin" + + # Dilate pre_mask + pre_dilate = pe.Node( + fsl.DilateImage( + operation="max", + kernel_shape="sphere", + kernel_size=3.0, + internal_datatype="char", + ), + name=f"pre_mask_dilate_{pipe_num}", + ) + + # Fix precision errors + # https://github.com/ANTsX/ANTs/wiki/Inputs-do-not-occupy-the-same-physical-space#fixing-precision-errors + print_header = pe.Node( + PrintHeader(what_information=4), name=f"print_header_{pipe_num}" + ) + set_direction = pe.Node(SetDirectionByMatrix(), name=f"set_direction_{pipe_num}") + + # Run N4 normally, force num_threads=1 for stability (images are + # small, no need for >1) + n4_correct = pe.Node( + ants.N4BiasFieldCorrection( + dimension=3, copy_header=True, bspline_fitting_distance=200 + ), + shrink_factor=2, + rescale_intensities=True, + name=f"n4_correct_{pipe_num}", + n_procs=1, + ) + + # Create a generous BET mask out of the bias-corrected EPI + skullstrip_first_pass = pe.Node( + fsl.BET(frac=0.2, mask=True, functional=False), + name=f"skullstrip_first_pass_{pipe_num}", + ) + + bet_dilate = pe.Node( + fsl.DilateImage( + operation="max", + kernel_shape="sphere", + kernel_size=6.0, + internal_datatype="char", + ), + name=f"skullstrip_first_dilate_{pipe_num}", + ) + + bet_mask = pe.Node(fsl.ApplyMask(), name=f"skullstrip_first_mask_{pipe_num}") + + # Use AFNI's unifize for T2 constrast + unifize = pe.Node( + afni_utils.Unifize( + t2=True, + outputtype="NIFTI_GZ", + args="-clfrac 0.2 -rbt 18.3 65.0 90.0", + out_file="uni.nii.gz", + ), + name=f"unifize_{pipe_num}", + ) + + # Run ANFI's 3dAutomask to extract a refined brain mask + skullstrip_second_pass = pe.Node( + preprocess.Automask(dilate=1, outputtype="NIFTI_GZ"), + name=f"skullstrip_second_pass_{pipe_num}", + ) + + # Take intersection of both masks + combine_masks = pe.Node( + fsl.BinaryMaths(operation="mul"), name=f"combine_masks_{pipe_num}" + ) + + # Compute masked brain + apply_mask = pe.Node(fsl.ApplyMask(), name=f"extract_ref_brain_bold_{pipe_num}") + + wf.connect( + [ + (inputNode, init_aff, [("FSL-AFNI-bold-ref", "fixed_image")]), + (inputNode, init_aff, [("FSL-AFNI-brain-mask", "fixed_image_mask")]), + (inputNode, init_aff, [("motion-basefile", "moving_image")]), + (init_aff, norm, [("output_transform", "initial_moving_transform")]), + (inputNode, norm, [("FSL-AFNI-bold-ref", "fixed_image")]), + (inputNode, norm, [("motion-basefile", "moving_image")]), + # Use the higher resolution and probseg for numerical stability in rounding + (inputNode, map_brainmask, [("FSL-AFNI-brain-probseg", "input_image")]), + (inputNode, map_brainmask, [("motion-basefile", "reference_image")]), + ( + norm, + map_brainmask, + [ + ("reverse_invert_flags", "invert_transform_flags"), + ("reverse_transforms", "transforms"), + ], + ), + (map_brainmask, binarize_mask, [("output_image", "in_file")]), + (binarize_mask, pre_dilate, [("out_file", "in_file")]), + (pre_dilate, print_header, [("out_file", "image")]), + (print_header, set_direction, [("header", "direction")]), + ( + inputNode, + set_direction, + [("motion-basefile", "infile"), ("motion-basefile", "outfile")], + ), + (set_direction, n4_correct, [("outfile", "mask_image")]), + (inputNode, n4_correct, [("motion-basefile", "input_image")]), + (n4_correct, skullstrip_first_pass, [("output_image", "in_file")]), + (skullstrip_first_pass, bet_dilate, [("mask_file", "in_file")]), + (bet_dilate, bet_mask, [("out_file", "mask_file")]), + (skullstrip_first_pass, bet_mask, [("out_file", "in_file")]), + (bet_mask, unifize, [("out_file", "in_file")]), + (unifize, skullstrip_second_pass, [("out_file", "in_file")]), + (skullstrip_first_pass, combine_masks, [("mask_file", "in_file")]), + (skullstrip_second_pass, combine_masks, [("out_file", "operand_file")]), + (unifize, apply_mask, [("out_file", "in_file")]), + (combine_masks, apply_mask, [("out_file", "mask_file")]), + (combine_masks, outputNode, [("out_file", "space-bold_desc-brain_mask")]), + (apply_mask, outputNode, [("out_file", "desc-unifized_bold")]), + ] + ) + + return wf + + @nodeblock( name="func_reorient", - config=["functional_preproc", "update_header"], - switch=["run"], - inputs=["bold"], - outputs=["desc-preproc_bold", "desc-reorient_bold"], + switch=["functional_preproc", "update_header", "run"], + option_key=["functional_preproc", "update_header", "deoblique"], + option_val=["warp", "refit"], + inputs=["bold", "tpattern", "tr"], + outputs=["desc-preproc_bold", "desc-reorient_bold", "tpattern"], ) def func_reorient(wf, cfg, strat_pool, pipe_num, opt=None): - """Reorient functional timeseries.""" - func_deoblique = pe.Node( - interface=afni_utils.Refit(), - name=f"func_deoblique_{pipe_num}", - mem_gb=0.68, - mem_x=(4664065662093477 / 1208925819614629174706176, "in_file"), - ) - func_deoblique.inputs.deoblique = True + """Deoblique and Reorient functional timeseries.""" + outputs = {} + if opt not in func_reorient.option_val: + raise ValueError( + f"\n[!] Error: Invalid option {opt} for func_reorient. \n" + f"Expected one of {func_reorient.option_val}" + ) - node, out = strat_pool.get_data("bold") - wf.connect(node, out, func_deoblique, "in_file") + if opt == "warp": + func_deoblique = pe.Node( + Function( + input_names=["in_file", "deoblique"], + output_names=["out_file"], + function=afni_3dwarp, + ), + name=f"func_deoblique_warp_{pipe_num}", + ) - func_reorient = pe.Node( + interpolate_node = pe.Node( + Function( + input_names=["timing_file", "target_slices", "00000000000000000"], + output_names=["out_file"], + function=interpolate_slice_timing, + ), + name=f"interpolate_slice_timing_{pipe_num}", + ) + + get_slices_node = pe.Node( + Function( + input_names=["nifti_file"], + output_names=["num_slices"], + function=get_num_slices, + ), + name=f"get_num_slices_{pipe_num}", + ) + wf.connect(func_deoblique, "out_file", get_slices_node, "nifti_file") + wf.connect(get_slices_node, "num_slices", interpolate_node, "target_slices") + + tpattern_node, tpattern = strat_pool.get_data("tpattern") + wf.connect(tpattern_node, tpattern, interpolate_node, "timing_file") + + outputs = {"tpattern": (interpolate_node, "out_file")} + + elif opt == "refit": + func_deoblique = pe.Node( + interface=afni_utils.Refit(), + name=f"func_deoblique_refit_{pipe_num}", + mem_gb=0.68, + mem_x=(4664065662093477 / 1208925819614629174706176, "in_file"), + ) + + func_reorient_node = pe.Node( interface=afni_utils.Resample(), name=f"func_reorient_{pipe_num}", mem_gb=0, mem_x=(0.0115, "in_file", "t"), ) - func_reorient.inputs.orientation = "RPI" - func_reorient.inputs.outputtype = "NIFTI_GZ" + node, out = strat_pool.get_data("bold") + func_deoblique.inputs.deoblique = True + wf.connect(node, out, func_deoblique, "in_file") + wf.connect(func_deoblique, "out_file", func_reorient_node, "in_file") - wf.connect(func_deoblique, "out_file", func_reorient, "in_file") + func_reorient_node.inputs.orientation = cfg.pipeline_setup["desired_orientation"] + func_reorient_node.inputs.outputtype = "NIFTI_GZ" - outputs = { - "desc-preproc_bold": (func_reorient, "out_file"), - "desc-reorient_bold": (func_reorient, "out_file"), - } + outputs.update( + { + "desc-preproc_bold": (func_reorient_node, "out_file"), + "desc-reorient_bold": (func_reorient_node, "out_file"), + } + ) return (wf, outputs) @@ -953,7 +1201,7 @@ def form_thr_string(thr): "space-bold_desc-brain_mask": { "Description": "mask of the skull-stripped input file" }, - "desc-ref_bold": { + "desc-unifized_bold": { "Description": "the ``bias_corrected_file`` after skull-stripping" }, }, @@ -1004,6 +1252,7 @@ def bold_mask_fsl_afni(wf, cfg, strat_pool, pipe_num, opt=None): # * Removed ``if not pre_mask`` conditional block # * Modified docstring to reflect local changes # * Refactored some variables and connections and updated style to match C-PAC codebase + # * Moved fsl-afni subworkflow into a separate function and added a function call in this nodeblock. # ORIGINAL WORK'S ATTRIBUTION NOTICE: # Copyright (c) 2016, the CRN developers team. @@ -1048,184 +1297,23 @@ def bold_mask_fsl_afni(wf, cfg, strat_pool, pipe_num, opt=None): # Modifications copyright (C) 2021 - 2024 C-PAC Developers - # Initialize transforms with antsAI - init_aff = pe.Node( - AI( - metric=("Mattes", 32, "Regular", 0.2), - transform=("Affine", 0.1), - search_factor=(20, 0.12), - principal_axes=False, - convergence=(10, 1e-6, 10), - verbose=True, - ), - name=f"init_aff_{pipe_num}", - n_procs=cfg.pipeline_setup["system_config"]["num_OMP_threads"], - ) - node, out = strat_pool.get_data("FSL-AFNI-bold-ref") - wf.connect(node, out, init_aff, "fixed_image") - - node, out = strat_pool.get_data("FSL-AFNI-brain-mask") - wf.connect(node, out, init_aff, "fixed_image_mask") - - init_aff.inputs.search_grid = (40, (0, 40, 40)) - - # Set up spatial normalization - norm = pe.Node( - ants.Registration( - winsorize_upper_quantile=0.98, - winsorize_lower_quantile=0.05, - float=True, - metric=["Mattes"], - metric_weight=[1], - radius_or_number_of_bins=[64], - transforms=["Affine"], - transform_parameters=[[0.1]], - number_of_iterations=[[200]], - convergence_window_size=[10], - convergence_threshold=[1.0e-9], - sampling_strategy=["Random", "Random"], - smoothing_sigmas=[[2]], - sigma_units=["mm", "mm", "mm"], - shrink_factors=[[2]], - sampling_percentage=[0.2], - use_histogram_matching=[True], - ), - name=f"norm_{pipe_num}", - n_procs=cfg.pipeline_setup["system_config"]["num_OMP_threads"], - ) - - node, out = strat_pool.get_data("FSL-AFNI-bold-ref") - wf.connect(node, out, norm, "fixed_image") - - map_brainmask = pe.Node( - ants.ApplyTransforms( - interpolation="BSpline", - float=True, - ), - name=f"map_brainmask_{pipe_num}", - ) - - # Use the higher resolution and probseg for numerical stability in rounding - node, out = strat_pool.get_data("FSL-AFNI-brain-probseg") - wf.connect(node, out, map_brainmask, "input_image") + fsl_afni_wf = fsl_afni_subworkflow(cfg, pipe_num, opt) - binarize_mask = pe.Node( - interface=fsl.maths.MathsCommand(), name=f"binarize_mask_{pipe_num}" - ) - binarize_mask.inputs.args = "-thr 0.85 -bin" - - # Dilate pre_mask - pre_dilate = pe.Node( - fsl.DilateImage( - operation="max", - kernel_shape="sphere", - kernel_size=3.0, - internal_datatype="char", - ), - name=f"pre_mask_dilate_{pipe_num}", - ) - - # Fix precision errors - # https://github.com/ANTsX/ANTs/wiki/Inputs-do-not-occupy-the-same-physical-space#fixing-precision-errors - print_header = pe.Node( - PrintHeader(what_information=4), name=f"print_header_{pipe_num}" - ) - set_direction = pe.Node(SetDirectionByMatrix(), name=f"set_direction_{pipe_num}") - - # Run N4 normally, force num_threads=1 for stability (images are - # small, no need for >1) - n4_correct = pe.Node( - ants.N4BiasFieldCorrection( - dimension=3, copy_header=True, bspline_fitting_distance=200 - ), - shrink_factor=2, - rescale_intensities=True, - name=f"n4_correct_{pipe_num}", - n_procs=1, - ) - - # Create a generous BET mask out of the bias-corrected EPI - skullstrip_first_pass = pe.Node( - fsl.BET(frac=0.2, mask=True, functional=False), - name=f"skullstrip_first_pass_{pipe_num}", - ) - - bet_dilate = pe.Node( - fsl.DilateImage( - operation="max", - kernel_shape="sphere", - kernel_size=6.0, - internal_datatype="char", - ), - name=f"skullstrip_first_dilate_{pipe_num}", - ) - - bet_mask = pe.Node(fsl.ApplyMask(), name=f"skullstrip_first_mask_{pipe_num}") - - # Use AFNI's unifize for T2 constrast - unifize = pe.Node( - afni_utils.Unifize( - t2=True, - outputtype="NIFTI_GZ", - args="-clfrac 0.2 -rbt 18.3 65.0 90.0", - out_file="uni.nii.gz", - ), - name=f"unifize_{pipe_num}", - ) - - # Run ANFI's 3dAutomask to extract a refined brain mask - skullstrip_second_pass = pe.Node( - preprocess.Automask(dilate=1, outputtype="NIFTI_GZ"), - name=f"skullstrip_second_pass_{pipe_num}", - ) - - # Take intersection of both masks - combine_masks = pe.Node( - fsl.BinaryMaths(operation="mul"), name=f"combine_masks_{pipe_num}" - ) - - # Compute masked brain - apply_mask = pe.Node(fsl.ApplyMask(), name=f"extract_ref_brain_bold_{pipe_num}") - - node, out = strat_pool.get_data(["motion-basefile"]) - - wf.connect( - [ - (node, init_aff, [(out, "moving_image")]), - (node, map_brainmask, [(out, "reference_image")]), - (node, norm, [(out, "moving_image")]), - (init_aff, norm, [("output_transform", "initial_moving_transform")]), - ( - norm, - map_brainmask, - [ - ("reverse_invert_flags", "invert_transform_flags"), - ("reverse_transforms", "transforms"), - ], - ), - (map_brainmask, binarize_mask, [("output_image", "in_file")]), - (binarize_mask, pre_dilate, [("out_file", "in_file")]), - (pre_dilate, print_header, [("out_file", "image")]), - (print_header, set_direction, [("header", "direction")]), - (node, set_direction, [(out, "infile"), (out, "outfile")]), - (set_direction, n4_correct, [("outfile", "mask_image")]), - (node, n4_correct, [(out, "input_image")]), - (n4_correct, skullstrip_first_pass, [("output_image", "in_file")]), - (skullstrip_first_pass, bet_dilate, [("mask_file", "in_file")]), - (bet_dilate, bet_mask, [("out_file", "mask_file")]), - (skullstrip_first_pass, bet_mask, [("out_file", "in_file")]), - (bet_mask, unifize, [("out_file", "in_file")]), - (unifize, skullstrip_second_pass, [("out_file", "in_file")]), - (skullstrip_first_pass, combine_masks, [("mask_file", "in_file")]), - (skullstrip_second_pass, combine_masks, [("out_file", "operand_file")]), - (unifize, apply_mask, [("out_file", "in_file")]), - (combine_masks, apply_mask, [("out_file", "mask_file")]), - ] - ) + for key in [ + "FSL-AFNI-bold-ref", + "FSL-AFNI-brain-mask", + "FSL-AFNI-brain-probseg", + "motion-basefile", + ]: + node, out = strat_pool.get_data(key) + wf.connect(node, out, fsl_afni_wf, f"inputspec.{key}") outputs = { - "space-bold_desc-brain_mask": (combine_masks, "out_file"), - "desc-ref_bold": (apply_mask, "out_file"), + "desc-unifized_bold": (fsl_afni_wf, "outputspec.desc-unifized_bold"), + "space-bold_desc-brain_mask": ( + fsl_afni_wf, + "outputspec.space-bold_desc-brain_mask", + ), } return (wf, outputs) @@ -1290,7 +1378,7 @@ def bold_mask_anatomical_refined(wf, cfg, strat_pool, pipe_num, opt=None): mem_x=(0.0115, "in_file", "t"), ) - func_reorient.inputs.orientation = "RPI" + func_reorient.inputs.orientation = cfg.pipeline_setup["desired_orientation"] func_reorient.inputs.outputtype = "NIFTI_GZ" wf.connect(func_deoblique, "out_file", func_reorient, "in_file") @@ -1466,31 +1554,20 @@ def bold_mask_anatomical_based(wf, cfg, strat_pool, pipe_num, opt=None): return (wf, outputs) -@nodeblock( - name="bold_mask_anatomical_resampled", - switch=[ - ["functional_preproc", "run"], - ["functional_preproc", "func_masking", "run"], - ], - option_key=["functional_preproc", "func_masking", "using"], - option_val="Anatomical_Resampled", - inputs=[ - "desc-preproc_bold", - "T1w-template-funcreg", - "space-template_desc-preproc_T1w", - "space-template_desc-T1w_mask", - ], - outputs=[ - "space-template_res-bold_desc-brain_T1w", - "space-template_desc-bold_mask", - "space-bold_desc-brain_mask", - ], -) -def bold_mask_anatomical_resampled(wf, cfg, strat_pool, pipe_num, opt=None): - """Resample anatomical brain mask to get BOLD brain mask in standard space. +def anat_brain_to_bold_res(wf_name, cfg, pipe_num): + wf = pe.Workflow(name=f"{wf_name}_{pipe_num}") + + inputNode = pe.Node( + util.IdentityInterface( + fields=["T1w-template-funcreg", "space-template_desc-head_T1w"] + ), + name="inputspec", + ) + outputNode = pe.Node( + util.IdentityInterface(fields=["space-template_res-bold_desc-head_T1w"]), + name="outputspec", + ) - Adapted from `DCAN Lab's BOLD mask method from the ABCD pipeline `_. - """ # applywarp --rel --interp=spline -i ${T1wImage} -r ${ResampRefIm} --premat=$FSLDIR/etc/flirtsch/ident.mat -o ${WD}/${T1wImageFile}.${FinalfMRIResolution} anat_brain_to_func_res = pe.Node( interface=fsl.ApplyWarp(), name=f"resample_anat_brain_in_standard_{pipe_num}" @@ -1501,14 +1578,35 @@ def bold_mask_anatomical_resampled(wf, cfg, strat_pool, pipe_num, opt=None): "anatomical_registration" ]["registration"]["FSL-FNIRT"]["identity_matrix"] - node, out = strat_pool.get_data("space-template_desc-preproc_T1w") - wf.connect(node, out, anat_brain_to_func_res, "in_file") + wf.connect( + inputNode, "space-template_desc-head_T1w", anat_brain_to_func_res, "in_file" + ) + wf.connect(inputNode, "T1w-template-funcreg", anat_brain_to_func_res, "ref_file") + + wf.connect( + anat_brain_to_func_res, + "out_file", + outputNode, + "space-template_res-bold_desc-head_T1w", + ) + return wf - node, out = strat_pool.get_data("T1w-template-funcreg") - wf.connect(node, out, anat_brain_to_func_res, "ref_file") +def anat_brain_mask_to_bold_res(wf_name, cfg, pipe_num): # Create brain masks in this space from the FreeSurfer output (changing resolution) # applywarp --rel --interp=nn -i ${FreeSurferBrainMask}.nii.gz -r ${WD}/${T1wImageFile}.${FinalfMRIResolution} --premat=$FSLDIR/etc/flirtsch/ident.mat -o ${WD}/${FreeSurferBrainMaskFile}.${FinalfMRIResolution}.nii.gz + wf = pe.Workflow(name=f"{wf_name}_{pipe_num}") + inputNode = pe.Node( + util.IdentityInterface( + fields=["space-template_desc-brain_mask", "space-template_desc-head_T1w"] + ), + name="inputspec", + ) + outputNode = pe.Node( + util.IdentityInterface(fields=["space-template_desc-bold_mask"]), + name="outputspec", + ) + anat_brain_mask_to_func_res = pe.Node( interface=fsl.ApplyWarp(), name=f"resample_anat_brain_mask_in_standard_{pipe_num}", @@ -1519,34 +1617,93 @@ def bold_mask_anatomical_resampled(wf, cfg, strat_pool, pipe_num, opt=None): "anatomical_registration" ]["registration"]["FSL-FNIRT"]["identity_matrix"] - node, out = strat_pool.get_data("space-template_desc-T1w_mask") - wf.connect(node, out, anat_brain_mask_to_func_res, "in_file") + wf.connect( + inputNode, + "space-template_desc-brain_mask", + anat_brain_mask_to_func_res, + "in_file", + ) + wf.connect( + inputNode, + "space-template_desc-head_T1w", + anat_brain_mask_to_func_res, + "ref_file", + ) + wf.connect( + anat_brain_mask_to_func_res, + "out_file", + outputNode, + "space-template_desc-bold_mask", + ) + + return wf + +@nodeblock( + name="bold_mask_anatomical_resampled", + switch=[ + ["functional_preproc", "run"], + ["functional_preproc", "template_space_func_masking", "run"], + ], + option_key=["functional_preproc", "template_space_func_masking", "using"], + option_val="Anatomical_Resampled", + inputs=[ + "T1w-template-funcreg", + "space-template_desc-head_T1w", + "space-template_desc-brain_mask", + ], + outputs=[ + "space-template_res-bold_desc-head_T1w", + "space-template_desc-bold_mask", + ], +) +def bold_mask_anatomical_resampled(wf, cfg, strat_pool, pipe_num, opt=None): + """Resample anatomical brain mask to get BOLD brain mask in standard space. + + Adapted from `DCAN Lab's BOLD mask method from the ABCD pipeline `_. + """ + anat_brain_to_func_res = anat_brain_to_bold_res( + wf_name="anat_brain_to_bold_res", cfg=cfg, pipe_num=pipe_num + ) + + node, out = strat_pool.get_data("space-template_desc-head_T1w") wf.connect( - anat_brain_to_func_res, "out_file", anat_brain_mask_to_func_res, "ref_file" + node, out, anat_brain_to_func_res, "inputspec.space-template_desc-head_T1w" ) - # Resample func mask in template space back to native space - func_mask_template_to_native = pe.Node( - interface=afni.Resample(), - name=f"resample_func_mask_to_native_{pipe_num}", - mem_gb=0, - mem_x=(0.0115, "in_file", "t"), + node, out = strat_pool.get_data("T1w-template-funcreg") + wf.connect(node, out, anat_brain_to_func_res, "inputspec.T1w-template-funcreg") + + # Create brain masks in this space from the FreeSurfer output (changing resolution) + # applywarp --rel --interp=nn -i ${FreeSurferBrainMask}.nii.gz -r ${WD}/${T1wImageFile}.${FinalfMRIResolution} --premat=$FSLDIR/etc/flirtsch/ident.mat -o ${WD}/${FreeSurferBrainMaskFile}.${FinalfMRIResolution}.nii.gz + anat_brain_mask_to_func_res = anat_brain_mask_to_bold_res( + wf_name="anat_brain_mask_to_bold_res", cfg=cfg, pipe_num=pipe_num ) - func_mask_template_to_native.inputs.resample_mode = "NN" - func_mask_template_to_native.inputs.outputtype = "NIFTI_GZ" + node, out = strat_pool.get_data("space-template_desc-brain_mask") wf.connect( - anat_brain_mask_to_func_res, "out_file", func_mask_template_to_native, "in_file" + node, + out, + anat_brain_mask_to_func_res, + "inputspec.space-template_desc-brain_mask", ) - node, out = strat_pool.get_data("desc-preproc_bold") - wf.connect(node, out, func_mask_template_to_native, "master") + wf.connect( + anat_brain_to_func_res, + "outputspec.space-template_res-bold_desc-head_T1w", + anat_brain_mask_to_func_res, + "inputspec.space-template_desc-head_T1w", + ) outputs = { - "space-template_res-bold_desc-brain_T1w": (anat_brain_to_func_res, "out_file"), - "space-template_desc-bold_mask": (anat_brain_mask_to_func_res, "out_file"), - "space-bold_desc-brain_mask": (func_mask_template_to_native, "out_file"), + "space-template_res-bold_desc-head_T1w": ( + anat_brain_to_func_res, + "outputspec.space-template_res-bold_desc-head_T1w", + ), + "space-template_desc-bold_mask": ( + anat_brain_mask_to_func_res, + "outputspec.space-template_desc-bold_mask", + ), } return (wf, outputs) @@ -1562,10 +1719,9 @@ def bold_mask_anatomical_resampled(wf, cfg, strat_pool, pipe_num, opt=None): option_val="CCS_Anatomical_Refined", inputs=[ ["desc-motion_bold", "desc-preproc_bold", "bold"], - "desc-brain_T1w", - ["desc-preproc_T1w", "desc-reorient_T1w", "T1w"], + ("desc-brain_T1w", "sbref", ["desc-preproc_T1w", "desc-reorient_T1w", "T1w"]), ], - outputs=["space-bold_desc-brain_mask", "desc-ref_bold"], + outputs=["space-bold_desc-brain_mask", "sbref"], ) def bold_mask_ccs(wf, cfg, strat_pool, pipe_num, opt=None): """Generate the BOLD mask by basing it off of the anatomical brain. @@ -1680,7 +1836,7 @@ def bold_mask_ccs(wf, cfg, strat_pool, pipe_num, opt=None): outputs = { "space-bold_desc-brain_mask": (intersect_mask, "out_file"), - "desc-ref_bold": (example_func_brain, "out_file"), + "sbref": (example_func_brain, "out_file"), } return (wf, outputs) @@ -1703,6 +1859,10 @@ def bold_mask_ccs(wf, cfg, strat_pool, pipe_num, opt=None): "Description": "The skull-stripped BOLD time-series.", "SkullStripped": True, }, + "desc-head_bold": { + "Description": "The non skull-stripped BOLD time-series.", + "SkullStripped": False, + }, }, ) def bold_masking(wf, cfg, strat_pool, pipe_num, opt=None): @@ -1714,8 +1874,8 @@ def bold_masking(wf, cfg, strat_pool, pipe_num, opt=None): func_edge_detect.inputs.expr = "a*b" func_edge_detect.inputs.outputtype = "NIFTI_GZ" - node, out = strat_pool.get_data("desc-preproc_bold") - wf.connect(node, out, func_edge_detect, "in_file_a") + node_head_bold, out_head_bold = strat_pool.get_data("desc-preproc_bold") + wf.connect(node_head_bold, out_head_bold, func_edge_detect, "in_file_a") node, out = strat_pool.get_data("space-bold_desc-brain_mask") wf.connect(node, out, func_edge_detect, "in_file_b") @@ -1723,11 +1883,62 @@ def bold_masking(wf, cfg, strat_pool, pipe_num, opt=None): outputs = { "desc-preproc_bold": (func_edge_detect, "out_file"), "desc-brain_bold": (func_edge_detect, "out_file"), + "desc-head_bold": (node_head_bold, out_head_bold), } return (wf, outputs) +@nodeblock( + name="template_space_bold_masking", + switch=[ + ["functional_preproc", "run"], + ["functional_preproc", "template_space_func_masking", "run"], + ], + inputs=[ + ("space-template_desc-head_bold", "space-template_desc-bold_mask"), + ], + outputs={ + "space-template_desc-preproc_bold": { + "Description": "The skull-stripped BOLD time-series.", + "SkullStripped": True, + }, + "space-template_desc-brain_bold": { + "Description": "The skull-stripped BOLD time-series.", + "SkullStripped": True, + }, + }, +) +def template_space_bold_masking( + wf: pe.Workflow, + cfg: "Configuration", + strat_pool: "ResourcePool", + pipe_num: int, + opt: None = None, +) -> NODEBLOCK_RETURN: + """Mask the bold in template space.""" + func_apply_mask = pe.Node( + interface=afni_utils.Calc(), + name=f"template_space_func_extract_brain_{pipe_num}", + ) + + func_apply_mask.inputs.expr = "a*b" + func_apply_mask.inputs.outputtype = "NIFTI_GZ" + + node_head_bold, out_head_bold = strat_pool.get_data("space-template_desc-head_bold") + wf.connect(node_head_bold, out_head_bold, func_apply_mask, "in_file_a") + + node, out = strat_pool.get_data("space-template_desc-bold_mask") + wf.connect(node, out, func_apply_mask, "in_file_b") + + outputs: POOL_RESOURCE_DICT = { + "space-template_desc-preproc_bold": (func_apply_mask, "out_file"), + "space-template_desc-brain_bold": (func_apply_mask, "out_file"), + } + + return wf, outputs + + @nodeblock( name="func_mean", switch=[ diff --git a/CPAC/func_preproc/tests/test_preproc_connections.py b/CPAC/func_preproc/tests/test_preproc_connections.py index f58380a7fd..6e316791f7 100644 --- a/CPAC/func_preproc/tests/test_preproc_connections.py +++ b/CPAC/func_preproc/tests/test_preproc_connections.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023-2024 C-PAC Developers +# Copyright (C) 2023-2025 C-PAC Developers # This file is part of C-PAC. @@ -26,14 +26,7 @@ from nipype.interfaces.utility import Function as NipypeFunction from nipype.pipeline.engine import Workflow as NipypeWorkflow -from CPAC.func_preproc.func_motion import ( - calc_motion_stats, - func_motion_correct, - func_motion_correct_only, - func_motion_estimates, - get_motion_ref, - motion_estimate_filter, -) +from CPAC.func_preproc import stack_motion_blocks from CPAC.func_preproc.func_preproc import func_normalize from CPAC.nuisance.nuisance import choose_nuisance_blocks from CPAC.pipeline.cpac_pipeline import connect_pipeline @@ -142,10 +135,6 @@ def test_motion_filter_connections( "functional_preproc": { "motion_estimates_and_correction": { "motion_correction": {"using": motion_correction}, - "motion_estimates": { - "calculate_motion_after": not calculate_motion_first, - "calculate_motion_first": calculate_motion_first, - }, "motion_estimate_filter": {"run": run, "filters": filters}, "run": True, }, @@ -183,26 +172,16 @@ def test_motion_filter_connections( rpool = ResourcePool(cfg=c) for resource in pre_resources: if resource.endswith("xfm"): - rpool.set_data( - resource, - before_this_test, - resource, - {}, - "", - f"created_before_this_test_{regtool}", - ) + node_name = f"created_before_this_test_{regtool}" + elif resource == "desc-movementParameters_motion": + node_name = f"created_before_this_test_{motion_correction}" else: - rpool.set_data( - resource, before_this_test, resource, {}, "", "created_before_this_test" - ) + node_name = "created_before_this_test" + rpool.set_data(resource, before_this_test, resource, {}, "", node_name) # set up blocks pipeline_blocks = [] - func_init_blocks = [] - func_motion_blocks = [] - func_preproc_blocks = [] - func_mask_blocks = [] - func_prep_blocks = [ - calc_motion_stats, + func_blocks = {key: [] for key in ["init", "preproc", "mask"]} + func_blocks["prep"] = [ func_normalize, [ coregistration_prep_vol, @@ -210,57 +189,8 @@ def test_motion_filter_connections( coregistration_prep_fmriprep, ], ] - # Motion Correction - func_motion_blocks = [] - if c[ - "functional_preproc", - "motion_estimates_and_correction", - "motion_estimates", - "calculate_motion_first", - ]: - func_motion_blocks = [ - get_motion_ref, - func_motion_estimates, - motion_estimate_filter, - ] - else: - func_motion_blocks = [ - get_motion_ref, - func_motion_correct, - motion_estimate_filter, - ] - if not rpool.check_rpool("desc-movementParameters_motion"): - if c[ - "functional_preproc", - "motion_estimates_and_correction", - "motion_estimates", - "calculate_motion_first", - ]: - func_blocks = ( - func_init_blocks - + func_motion_blocks - + func_preproc_blocks - + [func_motion_correct_only] - + func_mask_blocks - + func_prep_blocks - ) - else: - func_blocks = ( - func_init_blocks - + func_preproc_blocks - + func_motion_blocks - + func_mask_blocks - + func_prep_blocks - ) - else: - func_blocks = ( - func_init_blocks - + func_preproc_blocks - + func_motion_blocks - + func_mask_blocks - + func_prep_blocks - ) - pipeline_blocks += func_blocks + + pipeline_blocks += stack_motion_blocks(func_blocks, c, rpool) # Nuisance Correction generate_only = ( True not in c["nuisance_corrections", "2-nuisance_regression", "run"] @@ -299,6 +229,7 @@ def test_motion_filter_connections( "motion_correction", "using", ] + and "desc-movementParameters_motion" not in pre_resources ): # Only for [On, Off] + mcflirt, we should have at least one of each assert { diff --git a/CPAC/func_preproc/utils.py b/CPAC/func_preproc/utils.py index 4314452877..90b680612b 100644 --- a/CPAC/func_preproc/utils.py +++ b/CPAC/func_preproc/utils.py @@ -235,3 +235,27 @@ def notch_filter_motion( np.savetxt(filtered_motion_params, filtered_params.T, fmt="%f") return (filtered_motion_params, filter_design, filter_plot) + + +def interpolate_slice_timing( + timing_file, target_slices, out_file="adjusted_slice_timing.txt" +): + import os + + import numpy as np + + slice_timings = np.loadtxt(timing_file) + interpolated = np.interp( + np.linspace(0, len(slice_timings) - 1, target_slices), + np.arange(len(slice_timings)), + slice_timings, + ) + np.savetxt(out_file, interpolated) + return os.path.abspath(out_file) + + +def get_num_slices(nifti_file): + import nibabel as nib + + img = nib.load(nifti_file) + return img.shape[2] # Z dimension (slices) diff --git a/CPAC/image_utils/tests/test_smooth.py b/CPAC/image_utils/tests/test_smooth.py index d1f8a8ec98..bf1c79fd94 100644 --- a/CPAC/image_utils/tests/test_smooth.py +++ b/CPAC/image_utils/tests/test_smooth.py @@ -14,7 +14,6 @@ # You should have received a copy of the GNU Lesser General Public # License along with C-PAC. If not, see . -from logging import basicConfig, INFO import os import pytest @@ -26,7 +25,6 @@ from CPAC.utils.test_mocks import configuration_strategy_mock logger = getLogger("CPAC.image_utils.tests") -basicConfig(format="%(message)s", level=INFO) @pytest.mark.skip(reason="needs refactoring") diff --git a/CPAC/info.py b/CPAC/info.py index d776ad9971..de41799605 100644 --- a/CPAC/info.py +++ b/CPAC/info.py @@ -29,7 +29,7 @@ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -# Modifications Copyright (C) 2022-2023 C-PAC Developers +# Modifications Copyright (C) 2022-2025 C-PAC Developers # This file is part of C-PAC. """Metadata for building C-PAC. @@ -50,22 +50,20 @@ _version_extra = "dev1" -def get_cpac_gitversion(): - """CPAC version as reported by the last commit in git. - - Returns - ------- - None or str - - Version of C-PAC according to git. - """ - import os +def get_cpac_gitversion() -> str | None: + """CPAC version as reported by the last commit in git.""" + from importlib.resources import as_file, files import subprocess - gitpath = os.path.realpath(os.path.join(os.path.dirname(__file__), os.path.pardir)) - - gitpathgit = os.path.join(gitpath, ".git") - if not os.path.exists(gitpathgit): + with as_file(files("CPAC")) as _cpac: + gitpath = _cpac + gitpathgit = None + for _cpacpath in [gitpath, *gitpath.parents]: + git_dir = _cpacpath / ".git" + if git_dir.exists(): + gitpathgit = git_dir + break + if not gitpathgit: return None ver = None diff --git a/CPAC/longitudinal_pipeline/longitudinal_workflow.py b/CPAC/longitudinal_pipeline/longitudinal_workflow.py index 4229fc30c6..1ab06dceab 100644 --- a/CPAC/longitudinal_pipeline/longitudinal_workflow.py +++ b/CPAC/longitudinal_pipeline/longitudinal_workflow.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Copyright (C) 2020-2024 C-PAC Developers +# Copyright (C) 2020-2025 C-PAC Developers # This file is part of C-PAC. @@ -44,7 +44,7 @@ from CPAC.utils.interfaces.datasink import DataSink from CPAC.utils.interfaces.function import Function from CPAC.utils.strategy import Strategy -from CPAC.utils.utils import check_config_resources, check_prov_for_regtool +from CPAC.utils.utils import check_config_resources @nodeblock( @@ -254,10 +254,7 @@ def mask_longitudinal_T1w_brain(wf, cfg, strat_pool, pipe_num, opt=None): outputs=["space-template_desc-brain_T1w"], ) def warp_longitudinal_T1w_to_template(wf, cfg, strat_pool, pipe_num, opt=None): - xfm_prov = strat_pool.get_cpac_provenance( - "from-longitudinal_to-template_mode-image_xfm" - ) - reg_tool = check_prov_for_regtool(xfm_prov) + reg_tool = strat_pool.reg_tool("from-longitudinal_to-template_mode-image_xfm") num_cpus = cfg.pipeline_setup["system_config"]["max_cores_per_participant"] @@ -325,10 +322,9 @@ def warp_longitudinal_T1w_to_template(wf, cfg, strat_pool, pipe_num, opt=None): ], ) def warp_longitudinal_seg_to_T1w(wf, cfg, strat_pool, pipe_num, opt=None): - xfm_prov = strat_pool.get_cpac_provenance( + reg_tool = strat_pool.reg_tool( "from-longitudinal_to-T1w_mode-image_desc-linear_xfm" ) - reg_tool = check_prov_for_regtool(xfm_prov) num_cpus = cfg.pipeline_setup["system_config"]["max_cores_per_participant"] @@ -1204,6 +1200,7 @@ def func_longitudinal_template_wf(subject_id, strat_list, config): resampled_template.inputs.template = template resampled_template.inputs.template_name = template_name resampled_template.inputs.tag = tag + resampled_template.inputs.orientation = config["desired_orientation"] strat_init.update_resource_pool( {template_name: (resampled_template, "resampled_template")} diff --git a/CPAC/network_centrality/network_centrality.py b/CPAC/network_centrality/network_centrality.py index 21230b7385..ed919da7e5 100644 --- a/CPAC/network_centrality/network_centrality.py +++ b/CPAC/network_centrality/network_centrality.py @@ -1,4 +1,4 @@ -# Copyright (C) 2015-2024 C-PAC Developers +# Copyright (C) 2015-2025 C-PAC Developers # This file is part of C-PAC. @@ -14,6 +14,8 @@ # You should have received a copy of the GNU Lesser General Public # License along with C-PAC. If not, see . +"""Network centrality.""" + from pathlib import Path from typing import Optional @@ -23,7 +25,7 @@ from CPAC.network_centrality.utils import ThresholdOptionError from CPAC.pipeline.schema import valid_options from CPAC.utils.docs import docstring_parameter -from CPAC.utils.interfaces.afni import AFNI_GTE_21_1_1, ECM +from CPAC.utils.interfaces.afni import ECM @docstring_parameter( @@ -107,8 +109,6 @@ def create_centrality_wf( method_option, threshold_option = utils.check_centrality_params( method_option, threshold_option, test_thresh ) - # Eigenvector centrality and AFNI ≥ 21.1.1? - ecm_gte_21_1_01 = (method_option == "eigenvector_centrality") and AFNI_GTE_21_1_1 out_names = tuple(f"{method_option}_{x}" for x in weight_options) if base_dir is None: centrality_wf = pe.Workflow(name=wf_name) @@ -135,32 +135,21 @@ def create_centrality_wf( # Eigenvector centrality elif method_option == "eigenvector_centrality": - if ecm_gte_21_1_01: - afni_centrality_node = pe.MapNode( - ECM(environ={"OMP_NUM_THREADS": str(num_threads)}), - name="afni_centrality", - mem_gb=memory_gb, - iterfield=["do_binary", "out_file"], - ) - afni_centrality_node.inputs.out_file = [ - f"eigenvector_centrality_{w_option}.nii.gz" - for w_option in weight_options - ] - afni_centrality_node.inputs.do_binary = [ - w_option == "Binarized" for w_option in weight_options - ] - centrality_wf.connect( - afni_centrality_node, "out_file", output_node, "outfile_list" - ) - else: - afni_centrality_node = pe.Node( - ECM(environ={"OMP_NUM_THREADS": str(num_threads)}), - name="afni_centrality", - mem_gb=memory_gb, - ) - afni_centrality_node.inputs.out_file = ( - "eigenvector_centrality_merged.nii.gz" - ) + afni_centrality_node = pe.MapNode( + ECM(environ={"OMP_NUM_THREADS": str(num_threads)}), + name="afni_centrality", + mem_gb=memory_gb, + iterfield=["do_binary", "out_file"], + ) + afni_centrality_node.inputs.out_file = [ + f"eigenvector_centrality_{w_option}.nii.gz" for w_option in weight_options + ] + afni_centrality_node.inputs.do_binary = [ + w_option == "Binarized" for w_option in weight_options + ] + centrality_wf.connect( + afni_centrality_node, "out_file", output_node, "outfile_list" + ) afni_centrality_node.inputs.memory = memory_gb # 3dECM input only # lFCD @@ -172,8 +161,8 @@ def create_centrality_wf( ) afni_centrality_node.inputs.out_file = "lfcd_merged.nii.gz" - if not ecm_gte_21_1_01: - # Need to separate sub-briks except for 3dECM if AFNI > 21.1.01 + if method_option != "eigenvector_centrality": + # Need to separate sub-briks except for 3dECM sep_subbriks_node = pe.Node( Function( input_names=["nifti_file", "out_names"], diff --git a/CPAC/network_centrality/tests/test_network_centrality.py b/CPAC/network_centrality/tests/test_network_centrality.py index bca7ccb096..244b1cbba8 100644 --- a/CPAC/network_centrality/tests/test_network_centrality.py +++ b/CPAC/network_centrality/tests/test_network_centrality.py @@ -1,4 +1,4 @@ -# Copyright (C) 2015-2024 C-PAC Developers +# Copyright (C) 2015-2025 C-PAC Developers # This file is part of C-PAC. @@ -14,6 +14,9 @@ # You should have received a copy of the GNU Lesser General Public # License along with C-PAC. If not, see . +"""Tests for network centrality.""" + +from importlib.resources import as_file, files from itertools import combinations from pathlib import Path @@ -23,8 +26,9 @@ from CPAC.pipeline.schema import valid_options from CPAC.utils.interfaces.afni import AFNI_SEMVER -_DATA_DIR = Path(__file__).parent / "data" -"""Path to test data directory""" +with as_file(files("CPAC").joinpath("network_centrality/tests/data")) as _data: + _DATA_DIR = _data + """Path to test data directory""" @pytest.mark.parametrize("method_option", valid_options["centrality"]["method_options"]) diff --git a/CPAC/nuisance/bandpass.py b/CPAC/nuisance/bandpass.py index c5dc0f170d..754c343c9b 100644 --- a/CPAC/nuisance/bandpass.py +++ b/CPAC/nuisance/bandpass.py @@ -1,51 +1,103 @@ +# Copyright (C) 2019 - 2025 C-PAC Developers + +# This file is part of C-PAC. + +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. + +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . +"""Bandpass filtering utilities for C-PAC.""" + import os +from pathlib import Path import numpy as np +from numpy.typing import NDArray import nibabel as nib from scipy.fftpack import fft, ifft +from CPAC.utils.monitoring import IFLOGGER + def ideal_bandpass(data, sample_period, bandpass_freqs): - # Derived from YAN Chao-Gan 120504 based on REST. + """ + Apply ideal bandpass filtering to a 1D time series data using FFT. Derived from YAN Chao-Gan 120504 based on REST. + + Parameters + ---------- + data : NDArray + 1D time series data to be filtered. + sample_period : float + Length of sampling period in seconds. + bandpass_freqs : tuple + Tuple containing the bandpass frequencies (LowCutoff, HighCutoff). + + Returns + ------- + NDArray + Filtered time series data. + + """ sample_freq = 1.0 / sample_period sample_length = data.shape[0] + nyquist_freq = sample_freq / 2.0 - data_p = np.zeros(int(2 ** np.ceil(np.log2(sample_length)))) + # Length of zero-padded data for efficient FFT + N = int(2 ** np.ceil(np.log2(len(data)))) + data_p = np.zeros(N) data_p[:sample_length] = data LowCutoff, HighCutoff = bandpass_freqs if LowCutoff is None: # No lower cutoff (low-pass filter) low_cutoff_i = 0 - elif LowCutoff > sample_freq / 2.0: + elif LowCutoff > nyquist_freq: # Cutoff beyond fs/2 (all-stop filter) - low_cutoff_i = int(data_p.shape[0] / 2) + low_cutoff_i = int(N / 2) else: - low_cutoff_i = np.ceil(LowCutoff * data_p.shape[0] * sample_period).astype( - "int" - ) + low_cutoff_i = np.ceil(LowCutoff * N * sample_period).astype("int") - if HighCutoff > sample_freq / 2.0 or HighCutoff is None: + if HighCutoff is None or HighCutoff > nyquist_freq: # Cutoff beyond fs/2 or unspecified (become a highpass filter) - high_cutoff_i = int(data_p.shape[0] / 2) + high_cutoff_i = int(N / 2) else: - high_cutoff_i = np.fix(HighCutoff * data_p.shape[0] * sample_period).astype( - "int" - ) + high_cutoff_i = np.fix(HighCutoff * N * sample_period).astype("int") freq_mask = np.zeros_like(data_p, dtype="bool") freq_mask[low_cutoff_i : high_cutoff_i + 1] = True - freq_mask[data_p.shape[0] - high_cutoff_i : data_p.shape[0] + 1 - low_cutoff_i] = ( - True - ) + freq_mask[N - high_cutoff_i : N + 1 - low_cutoff_i] = True f_data = fft(data_p) - f_data[freq_mask is not True] = 0.0 + f_data[~freq_mask] = 0.0 return np.real_if_close(ifft(f_data)[:sample_length]) +def read_1D(one_D: Path | str) -> tuple[list[str], NDArray]: + """Parse a header from a 1D file, returing that header and a Numpy Array.""" + header = [] + with open(one_D, "r") as _f: + # Each leading line that doesn't start with a number goes into the header + for line in _f.readlines(): + try: + float(line.split()[0]) + break + except ValueError: + header.append(line) + + regressor = np.loadtxt(one_D, skiprows=len(header)) + return header, regressor + + def bandpass_voxels(realigned_file, regressor_file, bandpass_freqs, sample_period=None): - """Performs ideal bandpass filtering on each voxel time-series. + """Perform ideal bandpass filtering on each voxel time-series. Parameters ---------- @@ -74,6 +126,8 @@ def bandpass_voxels(realigned_file, regressor_file, bandpass_freqs, sample_perio sample_period = float(hdr.get_zooms()[3]) # Sketchy check to convert TRs in millisecond units if sample_period > 20.0: + message = f"Sample period ({sample_period}) is very large. Assuming milliseconds and converting to seconds." + IFLOGGER.warning(message) sample_period /= 1000.0 Y_bp = np.zeros_like(Y) @@ -106,18 +160,9 @@ def bandpass_voxels(realigned_file, regressor_file, bandpass_freqs, sample_perio img.to_filename(regressor_bandpassed_file) else: - with open(regressor_file, "r") as f: - header = [] - - # header wouldn't be longer than 5, right? I don't want to - # loop over the whole file - for i in range(5): - line = f.readline() - if line.startswith("#") or isinstance(line[0], str): - header.append(line) - - # usecols=[list] - regressor = np.loadtxt(regressor_file, skiprows=len(header)) + header: list[str] + regressor: NDArray + header, regressor = read_1D(regressor_file) Yc = regressor - np.tile(regressor.mean(0), (regressor.shape[0], 1)) Y_bp = np.zeros_like(Yc) diff --git a/CPAC/nuisance/nuisance.py b/CPAC/nuisance/nuisance.py index 45337a0c23..9504f0a3b7 100644 --- a/CPAC/nuisance/nuisance.py +++ b/CPAC/nuisance/nuisance.py @@ -1,4 +1,4 @@ -# Copyright (C) 2012-2024 C-PAC Developers +# Copyright (C) 2012-2025 C-PAC Developers # This file is part of C-PAC. @@ -14,6 +14,8 @@ # You should have received a copy of the GNU Lesser General Public # License along with C-PAC. If not, see . +"""Nusiance regression.""" + import os from typing import Literal @@ -29,6 +31,7 @@ from CPAC.nuisance.utils import ( find_offending_time_points, generate_summarize_tissue_mask, + load_censor_tsv, temporal_variance_mask, ) from CPAC.nuisance.utils.compcor import ( @@ -37,8 +40,8 @@ TR_string_to_float, ) from CPAC.pipeline import nipype_pipeline_engine as pe -from CPAC.pipeline.engine import ResourcePool -from CPAC.pipeline.nodeblock import nodeblock +from CPAC.pipeline.engine import NodeData, ResourcePool +from CPAC.pipeline.nodeblock import nodeblock, NODEBLOCK_RETURN, POOL_RESOURCE_DICT from CPAC.registration.registration import ( apply_transform, warp_timeseries_to_EPItemplate, @@ -50,7 +53,6 @@ from CPAC.utils.interfaces.function import Function from CPAC.utils.interfaces.pc import PC from CPAC.utils.monitoring import IFLOGGER -from CPAC.utils.utils import check_prov_for_regtool from .bandpass import afni_1dBandpass, bandpass_voxels @@ -75,8 +77,8 @@ def choose_nuisance_blocks(cfg, rpool, generate_only=False): ] apply_transform_using = to_template_cfg["apply_transform"]["using"] input_interface = { - "default": ("desc-preproc_bold", ["desc-preproc_bold", "bold"]), - "abcd": ("desc-preproc_bold", "bold"), + "default": ("desc-preproc_bold", ["desc-preproc_bold", "desc-reorient_bold"]), + "abcd": ("desc-preproc_bold", "desc-reorient_bold"), "single_step_resampling_from_stc": ("desc-preproc_bold", "desc-stc_bold"), }.get(apply_transform_using) if input_interface is not None: @@ -302,7 +304,7 @@ def gather_nuisance( raise ValueError(msg) try: - regressors = np.loadtxt(regressor_file) + regressors = load_censor_tsv(regressor_file, regressor_length) except (OSError, TypeError, UnicodeDecodeError, ValueError) as error: msg = f"Could not read regressor {regressor_type} from {regressor_file}." raise OSError(msg) from error @@ -382,7 +384,7 @@ def gather_nuisance( if custom_file_paths: for custom_file_path in custom_file_paths: try: - custom_regressor = np.loadtxt(custom_file_path) + custom_regressor = load_censor_tsv(custom_file_path, regressor_length) except: msg = "Could not read regressor {0} from {1}.".format( "Custom", custom_file_path @@ -421,7 +423,7 @@ def gather_nuisance( censor_volumes = np.ones((regressor_length,), dtype=int) else: try: - censor_volumes = np.loadtxt(regressor_file) + censor_volumes = load_censor_tsv(regressor_file, regressor_length) except: msg = ( f"Could not read regressor {regressor_type} from {regressor_file}." @@ -496,6 +498,104 @@ def gather_nuisance( return output_file_path, censor_indices +def offending_timepoints_connector( + nuisance_selectors, name="offending_timepoints_connector" +): + inputspec = pe.Node( + util.IdentityInterface( + fields=[ + "fd_j_file_path", + "fd_p_file_path", + "dvars_file_path", + ] + ), + name="inputspec", + ) + + wf = pe.Workflow(name=name) + + outputspec = pe.Node( + util.IdentityInterface(fields=["out_file"]), + name="outputspec", + ) + + censor_selector = nuisance_selectors.get("Censor") + + find_censors = pe.Node( + Function( + input_names=[ + "fd_j_file_path", + "fd_j_threshold", + "fd_p_file_path", + "fd_p_threshold", + "dvars_file_path", + "dvars_threshold", + "number_of_previous_trs_to_censor", + "number_of_subsequent_trs_to_censor", + ], + output_names=["out_file"], + function=find_offending_time_points, + as_module=True, + ), + name="find_offending_time_points", + ) + + if not censor_selector.get("thresholds"): + msg = "Censoring requested, but thresh_metric not provided." + raise ValueError(msg) + + for threshold in censor_selector["thresholds"]: + if "type" not in threshold or threshold["type"] not in [ + "DVARS", + "FD_J", + "FD_P", + ]: + msg = "Censoring requested, but with invalid threshold type." + raise ValueError(msg) + + if "value" not in threshold: + msg = "Censoring requested, but threshold not provided." + raise ValueError(msg) + + if threshold["type"] == "FD_J": + find_censors.inputs.fd_j_threshold = threshold["value"] + wf.connect(inputspec, "fd_j_file_path", find_censors, "fd_j_file_path") + + if threshold["type"] == "FD_P": + find_censors.inputs.fd_p_threshold = threshold["value"] + wf.connect(inputspec, "fd_p_file_path", find_censors, "fd_p_file_path") + + if threshold["type"] == "DVARS": + find_censors.inputs.dvars_threshold = threshold["value"] + wf.connect(inputspec, "dvars_file_path", find_censors, "dvars_file_path") + + if ( + censor_selector.get("number_of_previous_trs_to_censor") + and censor_selector["method"] != "SpikeRegression" + ): + find_censors.inputs.number_of_previous_trs_to_censor = censor_selector[ + "number_of_previous_trs_to_censor" + ] + + else: + find_censors.inputs.number_of_previous_trs_to_censor = 0 + + if ( + censor_selector.get("number_of_subsequent_trs_to_censor") + and censor_selector["method"] != "SpikeRegression" + ): + find_censors.inputs.number_of_subsequent_trs_to_censor = censor_selector[ + "number_of_subsequent_trs_to_censor" + ] + + else: + find_censors.inputs.number_of_subsequent_trs_to_censor = 0 + + wf.connect(find_censors, "out_file", outputspec, "out_file") + + return wf + + def create_regressor_workflow( nuisance_selectors, use_ants, @@ -1547,6 +1647,38 @@ def create_regressor_workflow( "functional_file_path", ) + if nuisance_selectors.get("Censor"): + if nuisance_selectors["Censor"]["method"] == "SpikeRegression": + offending_timepoints_connector_wf = offending_timepoints_connector( + nuisance_selectors + ) + nuisance_wf.connect( + [ + ( + inputspec, + offending_timepoints_connector_wf, + [("fd_j_file_path", "inputspec.fd_j_file_path")], + ), + ( + inputspec, + offending_timepoints_connector_wf, + [("fd_p_file_path", "inputspec.fd_p_file_path")], + ), + ( + inputspec, + offending_timepoints_connector_wf, + [("dvars_file_path", "inputspec.dvars_file_path")], + ), + ] + ) + + nuisance_wf.connect( + offending_timepoints_connector_wf, + "outputspec.out_file", + build_nuisance_regressors, + "censor_file_path", + ) + build_nuisance_regressors.inputs.selector = nuisance_selectors # Check for any regressors to combine into files @@ -1656,93 +1788,28 @@ def create_nuisance_regression_workflow(nuisance_selectors, name="nuisance_regre nuisance_wf = pe.Workflow(name=name) if nuisance_selectors.get("Censor"): - censor_methods = ["Kill", "Zero", "Interpolate", "SpikeRegression"] - - censor_selector = nuisance_selectors.get("Censor") - if censor_selector.get("method") not in censor_methods: - msg = ( - "Improper censoring method specified ({0}), " - "should be one of {1}.".format( - censor_selector.get("method"), censor_methods - ) - ) - raise ValueError(msg) - - find_censors = pe.Node( - Function( - input_names=[ - "fd_j_file_path", - "fd_j_threshold", - "fd_p_file_path", - "fd_p_threshold", - "dvars_file_path", - "dvars_threshold", - "number_of_previous_trs_to_censor", - "number_of_subsequent_trs_to_censor", - ], - output_names=["out_file"], - function=find_offending_time_points, - as_module=True, - ), - name="find_offending_time_points", + offending_timepoints_connector_wf = offending_timepoints_connector( + nuisance_selectors ) - - if not censor_selector.get("thresholds"): - msg = "Censoring requested, but thresh_metric not provided." - raise ValueError(msg) - - for threshold in censor_selector["thresholds"]: - if "type" not in threshold or threshold["type"] not in [ - "DVARS", - "FD_J", - "FD_P", - ]: - msg = "Censoring requested, but with invalid threshold type." - raise ValueError(msg) - - if "value" not in threshold: - msg = "Censoring requested, but threshold not provided." - raise ValueError(msg) - - if threshold["type"] == "FD_J": - find_censors.inputs.fd_j_threshold = threshold["value"] - nuisance_wf.connect( - inputspec, "fd_j_file_path", find_censors, "fd_j_file_path" - ) - - if threshold["type"] == "FD_P": - find_censors.inputs.fd_p_threshold = threshold["value"] - nuisance_wf.connect( - inputspec, "fd_p_file_path", find_censors, "fd_p_file_path" - ) - - if threshold["type"] == "DVARS": - find_censors.inputs.dvars_threshold = threshold["value"] - nuisance_wf.connect( - inputspec, "dvars_file_path", find_censors, "dvars_file_path" - ) - - if ( - censor_selector.get("number_of_previous_trs_to_censor") - and censor_selector["method"] != "SpikeRegression" - ): - find_censors.inputs.number_of_previous_trs_to_censor = censor_selector[ - "number_of_previous_trs_to_censor" - ] - - else: - find_censors.inputs.number_of_previous_trs_to_censor = 0 - - if ( - censor_selector.get("number_of_subsequent_trs_to_censor") - and censor_selector["method"] != "SpikeRegression" - ): - find_censors.inputs.number_of_subsequent_trs_to_censor = censor_selector[ - "number_of_subsequent_trs_to_censor" + nuisance_wf.connect( + [ + ( + inputspec, + offending_timepoints_connector_wf, + [("fd_j_file_path", "inputspec.fd_j_file_path")], + ), + ( + inputspec, + offending_timepoints_connector_wf, + [("fd_p_file_path", "inputspec.fd_p_file_path")], + ), + ( + inputspec, + offending_timepoints_connector_wf, + [("dvars_file_path", "inputspec.dvars_file_path")], + ), ] - - else: - find_censors.inputs.number_of_subsequent_trs_to_censor = 0 + ) # Use 3dTproject to perform nuisance variable regression nuisance_regression = pe.Node( @@ -1757,17 +1824,19 @@ def create_nuisance_regression_workflow(nuisance_selectors, name="nuisance_regre nuisance_regression.inputs.norm = False if nuisance_selectors.get("Censor"): - if nuisance_selectors["Censor"]["method"] == "SpikeRegression": - nuisance_wf.connect(find_censors, "out_file", nuisance_regression, "censor") - else: - if nuisance_selectors["Censor"]["method"] == "Interpolate": - nuisance_regression.inputs.cenmode = "NTRP" - else: - nuisance_regression.inputs.cenmode = nuisance_selectors["Censor"][ - "method" - ].upper() + if nuisance_selectors["Censor"]["method"] != "SpikeRegression": + nuisance_regression.inputs.cenmode = ( + "NTRP" + if nuisance_selectors["Censor"]["method"] == "Interpolate" + else nuisance_selectors["Censor"]["method"].upper() + ) - nuisance_wf.connect(find_censors, "out_file", nuisance_regression, "censor") + nuisance_wf.connect( + offending_timepoints_connector_wf, + "outputspec.out_file", + nuisance_regression, + "censor", + ) if nuisance_selectors.get("PolyOrt"): if not nuisance_selectors["PolyOrt"].get("degree"): @@ -1811,9 +1880,59 @@ def create_nuisance_regression_workflow(nuisance_selectors, name="nuisance_regre return nuisance_wf +def _default_frequency_filter( + filtering_wf: pe.Workflow, + bandpass_selector: dict, + inputspec: pe.Node, + outputspec: pe.Node, +) -> pe.Node: + """Return a frequency filter node.""" + frequency_filter = pe.Node( + Function( + input_names=[ + "realigned_file", + "regressor_file", + "bandpass_freqs", + "sample_period", + ], + output_names=["bandpassed_file", "regressor_file"], + function=bandpass_voxels, + as_module=True, + ), + name="frequency_filter", + mem_gb=0.5, + mem_x=(3811976743057169 / 151115727451828646838272, "realigned_file"), + ) + frequency_filter.inputs.bandpass_freqs = [ + bandpass_selector.get("bottom_frequency"), + bandpass_selector.get("top_frequency"), + ] + filtering_wf.connect( + [ + ( + inputspec, + frequency_filter, + [ + ("functional_file_path", "realigned_file"), + ("regressors_file_path", "regressor_file"), + ], + ), + ( + frequency_filter, + outputspec, + [ + ("bandpassed_file", "residual_file_path"), + ("regressor_file", "residual_regressor"), + ], + ), + ] + ) + return frequency_filter + + def filtering_bold_and_regressors( nuisance_selectors, name="filtering_bold_and_regressors" -): +) -> pe.Workflow: inputspec = pe.Node( util.IdentityInterface( fields=[ @@ -1826,6 +1945,7 @@ def filtering_bold_and_regressors( ), name="inputspec", ) + inputspec.inputs.nuisance_selectors = nuisance_selectors outputspec = pe.Node( util.IdentityInterface(fields=["residual_file_path", "residual_regressor"]), @@ -1841,42 +1961,8 @@ def filtering_bold_and_regressors( bandpass_method = "default" if bandpass_method == "default": - frequency_filter = pe.Node( - Function( - input_names=[ - "realigned_file", - "regressor_file", - "bandpass_freqs", - "sample_period", - ], - output_names=["bandpassed_file", "regressor_file"], - function=bandpass_voxels, - as_module=True, - ), - name="frequency_filter", - mem_gb=0.5, - mem_x=(3811976743057169 / 151115727451828646838272, "realigned_file"), - ) - - frequency_filter.inputs.bandpass_freqs = [ - bandpass_selector.get("bottom_frequency"), - bandpass_selector.get("top_frequency"), - ] - - filtering_wf.connect( - inputspec, "functional_file_path", frequency_filter, "realigned_file" - ) - - filtering_wf.connect( - inputspec, "regressors_file_path", frequency_filter, "regressor_file" - ) - - filtering_wf.connect( - frequency_filter, "bandpassed_file", outputspec, "residual_file_path" - ) - - filtering_wf.connect( - frequency_filter, "regressor_file", outputspec, "residual_regressor" + frequency_filter = _default_frequency_filter( + filtering_wf, bandpass_selector, inputspec, outputspec ) elif bandpass_method == "AFNI": @@ -1944,8 +2030,7 @@ def filtering_bold_and_regressors( outputs=["desc-preproc_bold", "desc-cleaned_bold"], ) def ICA_AROMA_FSLreg(wf, cfg, strat_pool, pipe_num, opt=None): - xfm_prov = strat_pool.get_cpac_provenance("from-T1w_to-template_mode-image_xfm") - reg_tool = check_prov_for_regtool(xfm_prov) + reg_tool = strat_pool.reg_tool("from-T1w_to-template_mode-image_xfm") if reg_tool != "fsl": return (wf, None) @@ -1991,8 +2076,7 @@ def ICA_AROMA_FSLreg(wf, cfg, strat_pool, pipe_num, opt=None): outputs=["desc-preproc_bold", "desc-cleaned_bold"], ) def ICA_AROMA_ANTsreg(wf, cfg, strat_pool, pipe_num, opt=None): - xfm_prov = strat_pool.get_cpac_provenance("from-bold_to-template_mode-image_xfm") - reg_tool = check_prov_for_regtool(xfm_prov) + reg_tool = strat_pool.reg_tool("from-bold_to-template_mode-image_xfm") if reg_tool != "ants": return (wf, None) @@ -2062,8 +2146,7 @@ def ICA_AROMA_ANTsreg(wf, cfg, strat_pool, pipe_num, opt=None): outputs=["desc-preproc_bold", "desc-cleaned_bold"], ) def ICA_AROMA_FSLEPIreg(wf, cfg, strat_pool, pipe_num, opt=None): - xfm_prov = strat_pool.get_cpac_provenance("from-bold_to-EPItemplate_mode-image_xfm") - reg_tool = check_prov_for_regtool(xfm_prov) + reg_tool = strat_pool.reg_tool("from-bold_to-EPItemplate_mode-image_xfm") if reg_tool != "fsl": return (wf, None) @@ -2115,8 +2198,7 @@ def ICA_AROMA_FSLEPIreg(wf, cfg, strat_pool, pipe_num, opt=None): outputs=["desc-preproc_bold", "desc-cleaned_bold"], ) def ICA_AROMA_ANTsEPIreg(wf, cfg, strat_pool, pipe_num, opt=None): - xfm_prov = strat_pool.get_cpac_provenance("from-bold_to-EPItemplate_mode-image_xfm") - reg_tool = check_prov_for_regtool(xfm_prov) + reg_tool = strat_pool.reg_tool("from-bold_to-EPItemplate_mode-image_xfm") if reg_tool != "ants": return (wf, None) @@ -2375,8 +2457,15 @@ def nuisance_regressors_generation_EPItemplate(wf, cfg, strat_pool, pipe_num, op inputs=[ ( "desc-preproc_bold", - "space-bold_desc-brain_mask", + "desc-reorient_bold", + "sbref", + [ + "space-bold_desc-brain_mask", + "space-template_desc-bold_mask", + "space-template_desc-brain_mask", + ], "from-bold_to-T1w_mode-image_desc-linear_xfm", + "from-template_to-bold_mode-image_xfm", "desc-movementParameters_motion", "framewise-displacement-jenkinson", "framewise-displacement-power", @@ -2404,7 +2493,13 @@ def nuisance_regressors_generation_EPItemplate(wf, cfg, strat_pool, pipe_num, op "lateral-ventricles-mask", "TR", ], - outputs=["desc-confounds_timeseries", "censor-indices"], + outputs={ + "desc-confounds_timeseries": {}, + "censor-indices": {}, + "space-bold_desc-brain_mask": { + "Description": "Binary brain mask of the BOLD functional time-series, transformed from template space." + }, + }, ) def nuisance_regressors_generation_T1w(wf, cfg, strat_pool, pipe_num, opt=None): return nuisance_regressors_generation(wf, cfg, strat_pool, pipe_num, opt, "T1w") @@ -2417,46 +2512,45 @@ def nuisance_regressors_generation( pipe_num: int, opt: dict, space: Literal["T1w", "bold"], -) -> tuple[Workflow, dict]: - """Generate nuisance regressors. - - Parameters - ---------- - wf : ~nipype.pipeline.engine.workflows.Workflow - - cfg : ~CPAC.utils.configuration.Configuration - - strat_pool : ~CPAC.pipeline.engine.ResourcePool - - pipe_num : int +) -> NODEBLOCK_RETURN: + """Generate nuisance regressors.""" + from CPAC.nuisance.utils.xfm import transform_bold_mask_to_native - opt : dict - - space : str - T1w or bold - - Returns - ------- - wf : nipype.pipeline.engine.workflows.Workflow - - outputs : dict - """ prefixes = [f"space-{space}_"] * 2 reg_tool = None + outputs: POOL_RESOURCE_DICT = {} + + brain_mask = ( + strat_pool.node_data("space-bold_desc-brain_mask") + if strat_pool.check_rpool("space-bold_desc-brain_mask") + else NodeData() + ) if space == "T1w": prefixes[0] = "" if strat_pool.check_rpool("from-template_to-T1w_mode-image_desc-linear_xfm"): - xfm_prov = strat_pool.get_cpac_provenance( + reg_tool = strat_pool.reg_tool( "from-template_to-T1w_mode-image_desc-linear_xfm" ) - reg_tool = check_prov_for_regtool(xfm_prov) + if brain_mask.node is NotImplemented: + if reg_tool and strat_pool.check_rpool( + ["space-template_desc-bold_mask", "space-template_desc-brain_mask"] + ): + outputs["space-bold_desc-brain_mask"] = ( + transform_bold_mask_to_native( + wf, strat_pool, cfg, pipe_num, reg_tool + ) + ) + brain_mask.node, brain_mask.out = outputs[ + "space-bold_desc-brain_mask" + ] elif space == "bold": - xfm_prov = strat_pool.get_cpac_provenance( + reg_tool = strat_pool.reg_tool( "from-EPItemplate_to-bold_mode-image_desc-linear_xfm" ) - reg_tool = check_prov_for_regtool(xfm_prov) if reg_tool is not None: use_ants = reg_tool == "ants" + else: + use_ants = False if cfg.switch_is_on( [ "functional_preproc", @@ -2493,8 +2587,12 @@ def nuisance_regressors_generation( node, out = strat_pool.get_data("desc-preproc_bold") wf.connect(node, out, regressors, "inputspec.functional_file_path") - node, out = strat_pool.get_data("space-bold_desc-brain_mask") - wf.connect(node, out, regressors, "inputspec.functional_brain_mask_file_path") + wf.connect( + brain_mask.node, + brain_mask.out, + regressors, + "inputspec.functional_brain_mask_file_path", + ) if strat_pool.check_rpool(f"desc-brain_{space}"): node, out = strat_pool.get_data(f"desc-brain_{space}") @@ -2656,12 +2754,13 @@ def nuisance_regressors_generation( node, out = strat_pool.get_data("TR") wf.connect(node, out, regressors, "inputspec.tr") - outputs = { - "desc-confounds_timeseries": (regressors, "outputspec.regressors_file_path"), - "censor-indices": (regressors, "outputspec.censor_indices"), - } + outputs["desc-confounds_timeseries"] = ( + regressors, + "outputspec.regressors_file_path", + ) + outputs["censor-indices"] = (regressors, "outputspec.censor_indices") - return (wf, outputs) + return wf, outputs def nuisance_regression(wf, cfg, strat_pool, pipe_num, opt, space, res=None): @@ -2766,7 +2865,6 @@ def nuisance_regression(wf, cfg, strat_pool, pipe_num, opt, space, res=None): filt = filtering_bold_and_regressors( opt, name=f"filtering_bold_and_regressors_{name_suff}" ) - filt.inputs.inputspec.nuisance_selectors = opt node, out = strat_pool.get_data( ["desc-confounds_timeseries", "parsed_regressors"] diff --git a/CPAC/nuisance/tests/regressors.1D b/CPAC/nuisance/tests/regressors.1D new file mode 100644 index 0000000000..d55945bd4e --- /dev/null +++ b/CPAC/nuisance/tests/regressors.1D @@ -0,0 +1,15 @@ +# Extra header +# extra header +# C-PAC 1.8.7.dev1 +# Nuisance regressors: +# RotY RotYDelay RotYSq RotYDelaySq RotX RotXDelay RotXSq RotXDelaySq RotZ RotZDelay RotZSq RotZDelaySq Y YDelay YSq YDelaySq X XDelay XSq XDelaySq Z ZDelay ZSq ZDelaySq aCompCorDetrendPC0 aCompCorDetrendPC1 aCompCorDetrendPC2 aCompCorDetrendPC3 aCompCorDetrendPC4 +0.064503015618032941 0.000000000000000000 0.004160639023820202 0.000000000000000000 0.071612848897811346 0.000000000000000000 0.005128400127260760 0.000000000000000000 -0.045875642036314265 0.000000000000000000 0.002104574532244045 0.000000000000000000 0.132890000000000008 0.000000000000000000 0.017659752100000002 0.000000000000000000 0.014942199999999999 0.000000000000000000 0.000223269340840000 0.000000000000000000 0.000408556000000000 0.000000000000000000 0.000000166918005136 0.000000000000000000 -0.022348500000000000 0.024816700000000001 -0.096326200000000001 0.157762999999999987 -0.097873799999999997 +0.031640849390966043 0.064503015618032941 0.001001143350181796 0.004160639023820202 0.128928108975928074 0.071612848897811346 0.016622457284108785 0.005128400127260760 -0.067560891370646151 -0.045875642036314265 0.004564474042796250 0.002104574532244045 0.031627599999999999 0.132890000000000008 0.001000305081760000 0.017659752100000002 0.038095700000000003 0.014942199999999999 0.001451282358490000 0.000223269340840000 -0.005307810000000000 0.000408556000000000 0.000028172846996100 0.000000166918005136 -0.064876000000000003 -0.013603499999999999 0.009020350000000000 -0.160142000000000007 -0.177807999999999994 +0.014566182605406878 0.011659654350684051 0.001782025477654622 0.001708282485349496 0.087538262826358210 0.084814056613328720 0.003050410763897181 0.001983217145137512 -0.041453889502682612 -0.041248724781196566 0.000887045295189055 0.001102853114798172 0.024593061637466357 0.019123515563283400 -0.001171834437865083 -0.001702326740091272 0.013267008686230538 0.014908354440170480 -0.000023048542269668 0.000030800663864303 -0.003147503026503373 -0.002156489951271478 -0.000212523379574746 -0.000134571632225604 -0.005279020489008680 0.003309414394962159 0.006218425399968431 0.006926438427946187 0.031874911370701621 +0.023012917432044880 0.023641462459337223 0.001353826869739763 0.001428748088263631 0.128401517423642225 0.127907328597750475 0.002936077845255422 0.001732591121410621 -0.064041009402203836 -0.065349619535801984 -0.001376339705694537 -0.000867347717315630 0.055371528230890282 0.047838664356472604 -0.003939704578469714 -0.004413819725322955 0.008626921921677059 0.013521224060128565 -0.000524131399781458 -0.000509996162422567 0.001399646015426790 0.002426771079716165 -0.000697817034458711 -0.000644064148730770 0.003453684797811343 0.004439728633043883 0.005528130051255496 -0.000681564743845684 0.027088427450170843 +0.025438893846313822 0.030058212923250879 0.000838561693597976 0.001085005134557843 0.158696217127646116 0.160188595362451003 0.002834979654468744 0.001871305030454243 -0.079495085073931035 -0.083080090516398086 -0.003568788021910289 -0.002826331376429190 0.082500133838064399 0.073831252771084988 -0.006214900864815498 -0.006543763203955914 0.000519334243296480 0.008630341137520037 -0.000923363158038725 -0.000927750776503564 0.005165821347348335 0.005851034226762506 -0.001054704872395450 -0.001043041584010332 0.012752740283469200 0.004786640061712925 0.012289830660907162 -0.008745532683606035 0.014261415118720363 +0.021743016120035281 0.029688950895877426 0.000290547599874028 0.000682055571198300 0.175549364989970313 0.178338230890874111 0.002486643991830800 0.002149192970833630 -0.085377454115175486 -0.091489126463240492 -0.005383312549059558 -0.004535185285883645 0.102288251365551003 0.094066918293276736 -0.007766221033112258 -0.007876677356441979 -0.010112433374632405 0.000319385240548675 -0.001198648271548705 -0.001193505340585474 0.008037366757553616 0.007980258888708817 -0.001242736103775270 -0.001273598198058523 0.020974706057590668 0.005751802778007228 0.025351389814577394 -0.017180756363741379 -0.003956879522184370 +0.013525050094767123 0.023039913400015079 -0.000213791695822321 0.000249432472712464 0.178794499964418374 0.182090614749512603 0.001668344371008412 0.002226367140418777 -0.081444170893389012 -0.089634493861210238 -0.006575553895215308 -0.005785817468059847 0.112188805335497160 0.106323654207989879 -0.008527087208204130 -0.008379970470761666 -0.021551792557092900 -0.010410526495855658 -0.001350988613004632 -0.001312369367927021 0.010150399365352503 0.009047754995696919 -0.001267065761949068 -0.001322015050183638 0.027086406796860162 0.008769045224622980 0.041260717228531141 -0.025783341088905919 -0.023130294003556602 +0.003710293144471088 0.012303012925884141 -0.000591683949645386 -0.000159272972606234 0.170628799324984620 0.173931286958495634 0.000283113801796188 0.001792439708046661 -0.069705778794223461 -0.078851018906234180 -0.007027047515815758 -0.006451875040969878 0.111350260801486828 0.109587738981351920 -0.008599721245876775 -0.008202102875302755 -0.031732073497397532 -0.021834007346710128 -0.001401093147591972 -0.001318145135788918 0.011803916636990694 0.009558331079300939 -0.001174308952196117 -0.001222014617445004 0.030766413414606002 0.014584179038094797 0.055050504861566943 -0.034070573320800129 -0.038211308729750156 +-0.004234132549489960 0.000832649040004215 -0.000775011343076728 -0.000476609472781693 0.154706450373287646 0.158080166174354941 -0.001594467727120446 0.000677513943272737 -0.053855672812893871 -0.062403402297765788 -0.006775880525707196 -0.006520249171448194 0.100795197589240160 0.104212055737311265 -0.008213823778438519 -0.007620791647640451 -0.038786879957853938 -0.031921725342639970 -0.001382244593213621 -0.001261079549196342 0.013326020461721926 0.010106105453167591 -0.001035584634549134 -0.001043215412680393 0.032184969062050033 0.022685626981519318 0.061584251842384724 -0.041209431181336478 -0.044960991839340970 +-0.007277482359979987 -0.007732524875134966 -0.000730772864804432 -0.000639221128232586 0.134982773383533428 0.139112331565989317 -0.003731084894624379 -0.001083447029356628 -0.038226479638264539 -0.044608858813448345 -0.006004861339980094 -0.006093216205261694 0.083174466753920082 0.091804401122952045 -0.007653794404152313 -0.006957769820022970 -0.041563433510437883 -0.038870841259792399 -0.001331418827716966 -0.001192680917598046 0.014941405278128142 0.011168243606804554 -0.000922586836031674 -0.000868492699062700 0.031582380224112708 0.031133381053542616 0.057080842480777161 -0.046093261482679442 -0.041188612349529960 diff --git a/CPAC/nuisance/tests/test_bandpass.py b/CPAC/nuisance/tests/test_bandpass.py new file mode 100644 index 0000000000..2fbe25fd35 --- /dev/null +++ b/CPAC/nuisance/tests/test_bandpass.py @@ -0,0 +1,166 @@ +# Copyright (C) 2022 - 2025 C-PAC Developers + +# This file is part of C-PAC. + +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. + +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . +"""Tests for bandpass filters.""" + +from importlib.abc import Traversable +from importlib.resources import files +from os import getenv +from pathlib import Path + +from networkx import DiGraph +import numpy as np +from numpy.typing import NDArray +import pytest +import nibabel as nib +from scipy.fft import fft + +from CPAC.nuisance.bandpass import ideal_bandpass, read_1D +from CPAC.nuisance.nuisance import filtering_bold_and_regressors +from CPAC.nuisance.utils.utils import load_censor_tsv +from CPAC.pipeline.engine import ResourcePool +from CPAC.pipeline.nipype_pipeline_engine import Workflow +from CPAC.pipeline.test.test_engine import _download +from CPAC.utils.configuration import Preconfiguration +from CPAC.utils.tests.osf import download_file + +RAW_ONE_D: Traversable = files("CPAC").joinpath("nuisance/tests/regressors.1D") + + +class TestResourcePool(ResourcePool): + """ResourcePool with OSF download function.""" + + def osf(self, resource: str, file: str, destination: Path, index: int) -> None: + """Download a file from the Open Science Framework.""" + _download(self, resource, download_file, file, destination, index) + + +@pytest.mark.parametrize("start_line", list(range(6))) +def test_read_1D(start_line: int, tmp_path: Path) -> None: + """Test the correct number of rows are read when reading a 1D file.""" + regressor: Path = tmp_path / f"regressor_startAtL{start_line}.1D" + # create a regressor.1D file with (5 - ``start_line``) lines of header + with ( + RAW_ONE_D.open("r", encoding="utf-8") as _raw, + regressor.open("w", encoding="utf-8") as _test_file, + ): + for line in _raw.readlines()[start_line:]: + _test_file.write(line) + header: list[str] + data: NDArray + header, data = read_1D(regressor) + # should get the same array no matter how many lines of header + assert data.shape == (10, 29) + # all header lines should be captured + assert len(header) == 5 - start_line + + +@pytest.mark.parametrize( + "lowcut, highcut, in_freq, out_freq", + [ + (0.005, 0.05, 0.01, 0.2), + (0.01, 0.1, 0.02, 0.15), + (0.02, 0.08, 0.04, 0.12), + (None, 0.1, 0.02, 0.15), + (0.2, None, 0.22, 0.1), + ], +) +def test_ideal_bandpass_with_various_cutoffs(lowcut, highcut, in_freq, out_freq): + """Test the ideal bandpass filter with various cutoff frequencies.""" + sample_period = 1.0 + t = np.arange(512) * sample_period + signal = np.sin(2 * np.pi * in_freq * t) + np.sin(2 * np.pi * out_freq * t) + + filtered = ideal_bandpass(signal, sample_period, (lowcut, highcut)) + + freqs = np.fft.fftfreq(len(signal), d=sample_period) + orig_fft = np.abs(fft(signal)) + filt_fft = np.abs(fft(filtered)) + + idx_in = np.argmin(np.abs(freqs - in_freq)) + idx_out = np.argmin(np.abs(freqs - out_freq)) + + assert filt_fft[idx_in] > 0.5 * orig_fft[idx_in] + assert filt_fft[idx_out] < 0.1 * orig_fft[idx_out] + + +@pytest.mark.parametrize("sample_period", [1.0, 1000.0]) +def test_ideal_bandpass_cutoffs_clamped_to_nyquist(sample_period): + """Test that ideal_bandpass clamps cutoffs to Nyquist frequency.""" + N = 512 + t = np.arange(N) * sample_period + nyquist = 0.5 / sample_period + + freq_below = nyquist * 0.95 + freq_above = nyquist * 1.05 + + signal = np.sin(2 * np.pi * freq_below * t) + np.sin(2 * np.pi * freq_above * t) + + lowcut = nyquist + 0.01 + highcut = nyquist + 0.1 + + filtered = ideal_bandpass(signal, sample_period, (lowcut, highcut)) + + freqs = np.fft.fftfreq(N, d=sample_period) + filt_fft = np.abs(fft(filtered)) + + idx_below = np.argmin(np.abs(freqs - freq_below)) + idx_above = np.argmin(np.abs(freqs - freq_above)) + + assert filt_fft[idx_below] < 1e-3 + assert filt_fft[idx_above] < 1e-3 + + +@pytest.mark.skipif( + not getenv("OSF_DATA"), + reason="OSF API key not set in OSF_DATA environment variable", +) +def test_frequency_filter(tmp_path: Path) -> None: + """Test that the bandpass filter works as expected.""" + cfg = Preconfiguration("benchmark-FNIRT") + rpool = TestResourcePool(cfg) + wf = Workflow("bandpass_filtering", base_dir=str(tmp_path)) + index = 0 + for resource, file in { + "realigned_file": "residuals.nii.gz", + "regressor_file": "regressors.1D", + }.items(): + rpool.osf(resource, file, tmp_path, index) + index += 1 + + filt = filtering_bold_and_regressors( + cfg["nuisance_corrections", "2-nuisance_regression", "Regressors"][0] + ) + residuals = rpool.node_data("realigned_file") + regressors = rpool.node_data("regressor_file") + wf.connect( + [ + (residuals.node, filt, [(residuals.out, "inputspec.functional_file_path")]), + ( + regressors.node, + filt, + [(regressors.out, "inputspec.regressors_file_path")], + ), + ] + ) + res: DiGraph = wf.run() + out_node = next(iter(res.nodes)) + output = out_node.run() + trs = nib.load(output.outputs.bandpassed_file).header["dim"][4] # type: ignore[reportPrivateImportUsage] + array = load_censor_tsv(output.outputs.regressor_file, trs) + assert not all( + [array.min() == 0, array.max() == 0, array.sum() == 0] + ), "Bandpass filter filtered all signals." diff --git a/CPAC/nuisance/tests/test_utils.py b/CPAC/nuisance/tests/test_utils.py index 724d536b63..24bbc0660e 100644 --- a/CPAC/nuisance/tests/test_utils.py +++ b/CPAC/nuisance/tests/test_utils.py @@ -1,35 +1,55 @@ -from logging import basicConfig, INFO +# Copyright (C) 2019-2025 C-PAC Developers + +# This file is part of C-PAC. + +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. + +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . +"""Test nuisance utilities.""" + +from importlib.resources import as_file, files import os +from pathlib import Path +from random import randint import tempfile import numpy as np -import pkg_resources as p import pytest -from CPAC.nuisance.utils import calc_compcor_components, find_offending_time_points +from CPAC.nuisance.utils import ( + calc_compcor_components, + find_offending_time_points, + load_censor_tsv, +) from CPAC.utils.monitoring.custom_logging import getLogger logger = getLogger("CPAC.nuisance.tests") -basicConfig(format="%(message)s", level=INFO) -mocked_outputs = p.resource_filename( - "CPAC", os.path.join("nuisance", "tests", "motion_statistics") -) +_mocked_outputs = files("CPAC").joinpath("nuisance/tests/motion_statistics") @pytest.mark.skip(reason="needs refactoring") def test_find_offending_time_points(): dl_dir = tempfile.mkdtemp() os.chdir(dl_dir) - - censored = find_offending_time_points( - os.path.join(mocked_outputs, "FD_J.1D"), - os.path.join(mocked_outputs, "FD_P.1D"), - os.path.join(mocked_outputs, "DVARS.1D"), - 2.0, - 2.0, - "1.5SD", - ) + with as_file(_mocked_outputs) as mocked_outputs: + censored = find_offending_time_points( + str(mocked_outputs / "FD_J.1D"), + str(mocked_outputs / "FD_P.1D"), + str(mocked_outputs / "DVARS.1D"), + 2.0, + 2.0, + "1.5SD", + ) censored = np.loadtxt(censored).astype(bool) @@ -43,4 +63,21 @@ def test_calc_compcor_components(): compcor_filename = calc_compcor_components(data_filename, 5, mask_filename) logger.info("compcor components written to %s", compcor_filename) - assert 0 == 1 + + +@pytest.mark.parametrize("header", [True, False]) +def test_load_censor_tsv(header: bool, tmp_path: Path) -> None: + """Test loading of censor tsv files with and without headers.""" + expected_length = 3 + filepath = tmp_path / "censor.tsv" + with filepath.open("w") as f: + if header: + f.write("censor\n") + for i in range(expected_length): + f.write(f"{randint(0, 1)}\n") + censors = load_censor_tsv(str(filepath), expected_length) + assert ( + censors.shape[0] == expected_length + ), "Length of censors does not match expected length" + with pytest.raises(ValueError, match="expected length"): + load_censor_tsv(str(filepath), expected_length + 1) diff --git a/CPAC/nuisance/utils/__init__.py b/CPAC/nuisance/utils/__init__.py index 4fa7decfb9..dc18a10e16 100644 --- a/CPAC/nuisance/utils/__init__.py +++ b/CPAC/nuisance/utils/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2019-2024 C-PAC Developers +# Copyright (C) 2019-2025 C-PAC Developers # This file is part of C-PAC. @@ -21,6 +21,7 @@ from .utils import ( find_offending_time_points, generate_summarize_tissue_mask, + load_censor_tsv, NuisanceRegressor, temporal_variance_mask, ) @@ -30,6 +31,7 @@ "compcor", "find_offending_time_points", "generate_summarize_tissue_mask", + "load_censor_tsv", "NuisanceRegressor", "temporal_variance_mask", ] diff --git a/CPAC/nuisance/utils/compcor.py b/CPAC/nuisance/utils/compcor.py index 9de8e3a918..8d17de23d1 100644 --- a/CPAC/nuisance/utils/compcor.py +++ b/CPAC/nuisance/utils/compcor.py @@ -91,18 +91,33 @@ def cosine_filter( failure_mode="error", ): """ - `cosine_filter` adapted from Nipype. + Apply cosine filter to the input BOLD image using the discrete cosine transform (DCT) method. + + Adapted from nipype implementation. https://github.com/nipy/nipype/blob/d353f0d/nipype/algorithms/confounds.py#L1086-L1107 + It removes the low-frequency drift from the voxel time series. The filtered image is saved to disk. - https://github.com/nipy/nipype/blob/d353f0d/nipype/algorithms/confounds.py#L1086-L1107 Parameters ---------- - input_image_path : string - Bold image to be filtered. + input_image_path : str + Path to the BOLD image to be filtered. timestep : float - 'Repetition time (TR) of series (in sec) - derived from image header if unspecified' - period_cut : float - Minimum period (in sec) for DCT high-pass filter, nipype default value: 128. + Repetition time (TR) of the series (in seconds). Derived from image header if unspecified. + period_cut : float, optional + Minimum period (in seconds) for the DCT high-pass filter. Default value is 128. + remove_mean : bool, optional + Whether to remove the mean from the voxel time series before filtering. Default is True. + axis : int, optional + The axis along which to apply the filter. Default is -1 (last axis). + failure_mode : {'error', 'ignore'}, optional + Specifies how to handle failure modes. If set to 'error', the function raises an error. + If set to 'ignore', it returns the input data unchanged in case of failure. Default is 'error'. + + Returns + ------- + cosfiltered_img : str + Path to the filtered BOLD image. + """ # STATEMENT OF CHANGES: # This function is derived from sources licensed under the Apache-2.0 terms, @@ -113,6 +128,7 @@ def cosine_filter( # * Removed caluclation and return of `non_constant_regressors` # * Modified docstring to reflect local changes # * Updated style to match C-PAC codebase + # * Updated to use generator and iterate over voxel time series to optimize memory usage. # ORIGINAL WORK'S ATTRIBUTION NOTICE: # Copyright (c) 2009-2016, Nipype developers @@ -132,41 +148,74 @@ def cosine_filter( # Prior to release 0.12, Nipype was licensed under a BSD license. # Modifications copyright (C) 2019 - 2024 C-PAC Developers - from nipype.algorithms.confounds import _cosine_drift, _full_rank + try: - input_img = nib.load(input_image_path) - input_data = input_img.get_fdata() + def voxel_generator(): + for i in range(datashape[0]): + for j in range(datashape[1]): + for k in range(datashape[2]): + yield input_data[i, j, k, :] - datashape = input_data.shape - timepoints = datashape[axis] - if datashape[0] == 0 and failure_mode != "error": - return input_data, np.array([]) + from nipype.algorithms.confounds import _cosine_drift, _full_rank - input_data = input_data.reshape((-1, timepoints)) + input_img = nib.load(input_image_path) + input_data = input_img.get_fdata() + datashape = input_data.shape + timepoints = datashape[axis] + if datashape[0] == 0 and failure_mode != "error": + return input_data, np.array([]) - frametimes = timestep * np.arange(timepoints) - X = _full_rank(_cosine_drift(period_cut, frametimes))[0] + frametimes = timestep * np.arange(timepoints) + X_full = _full_rank(_cosine_drift(period_cut, frametimes))[0] - betas = np.linalg.lstsq(X, input_data.T)[0] + # Generate X with and without the mean column + X_with_mean = X_full + X_without_mean = X_full[:, :-1] if X_full.shape[1] > 1 else X_full - if not remove_mean: - X = X[:, :-1] - betas = betas[:-1] + # Reshape the input data to bring the time dimension to the last axis if it's not already + if axis != -1: + reshaped_data = np.moveaxis(input_data, axis, -1) + else: + reshaped_data = input_data + + reshaped_output_data = np.zeros_like(reshaped_data) + + # Choose the appropriate X matrix + X = X_without_mean if remove_mean else X_with_mean - residuals = input_data - X.dot(betas).T + voxel_gen = voxel_generator() - output_data = residuals.reshape(datashape) + for i in range(reshaped_data.shape[0]): + IFLOGGER.info( + f"calculating {i+1} of {reshaped_data.shape[0]} row of voxels" + ) + for j in range(reshaped_data.shape[1]): + for k in range(reshaped_data.shape[2]): + voxel_time_series = next(voxel_gen) + betas = np.linalg.lstsq(X, voxel_time_series.T, rcond=None)[0] + + residuals = voxel_time_series - X.dot(betas) + reshaped_output_data[i, j, k, :] = residuals + + # Move the time dimension back to its original position if it was reshaped + if axis != -1: + output_data = np.moveaxis(reshaped_output_data, -1, axis) + else: + output_data = reshaped_output_data - hdr = input_img.header - output_img = nib.Nifti1Image(output_data, header=hdr, affine=input_img.affine) + hdr = input_img.header + output_img = nib.Nifti1Image(output_data, header=hdr, affine=input_img.affine) + file_name = input_image_path[input_image_path.rindex("/") + 1 :] - file_name = input_image_path[input_image_path.rindex("/") + 1 :] + cosfiltered_img = os.path.join(os.getcwd(), file_name) - cosfiltered_img = os.path.join(os.getcwd(), file_name) + output_img.to_filename(cosfiltered_img) - output_img.to_filename(cosfiltered_img) + return cosfiltered_img - return cosfiltered_img + except Exception as e: + message = f"Error in cosine_filter: {e}" + IFLOGGER.error(message) def fallback_svd(a, full_matrices=True, compute_uv=True): diff --git a/CPAC/nuisance/utils/utils.py b/CPAC/nuisance/utils/utils.py index db6667dcb3..49ac12ac99 100644 --- a/CPAC/nuisance/utils/utils.py +++ b/CPAC/nuisance/utils/utils.py @@ -1,4 +1,4 @@ -# Copyright (C) 2019-2024 C-PAC Developers +# Copyright (C) 2019-2025 C-PAC Developers # This file is part of C-PAC. @@ -21,6 +21,7 @@ import re from typing import Optional +import numpy as np from nipype.interfaces import afni, ants, fsl import nipype.interfaces.utility as util from nipype.pipeline.engine import Workflow @@ -860,3 +861,19 @@ def encode(selector: dict) -> str: def __repr__(self) -> str: """Return a string representation of the nuisance regressor.""" return NuisanceRegressor.encode(self.selector) + + +def load_censor_tsv(filepath: str, expected_length: int) -> np.ndarray: + """Load censor TSV and verify length.""" + header = False + censor = np.empty((0)) + try: + censor = np.loadtxt(filepath) + except ValueError: + header = True + if header or censor.shape[0] == expected_length + 1: + censor = np.loadtxt(filepath, skiprows=1) + if censor.shape[0] == expected_length: + return censor + msg = f"Censor file length ({censor.shape[0]}) does not match expected length ({expected_length})." + raise ValueError(msg) diff --git a/CPAC/nuisance/utils/xfm.py b/CPAC/nuisance/utils/xfm.py new file mode 100644 index 0000000000..3ee1b59421 --- /dev/null +++ b/CPAC/nuisance/utils/xfm.py @@ -0,0 +1,69 @@ +# Copyright (C) 2025 C-PAC Developers + +# This file is part of C-PAC. + +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. + +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . +"""Transformation utilities for nuisance regression.""" + +from typing import cast, Literal + +from nipype.pipeline.engine import Workflow + +from CPAC.pipeline.engine import ResourcePool +from CPAC.registration.registration import apply_transform +from CPAC.utils.configuration import Configuration + + +def transform_bold_mask_to_native( + wf: Workflow, + strat_pool: ResourcePool, + cfg: Configuration, + pipe_num: int, + reg_tool: Literal["ants", "fsl"], +) -> tuple[Workflow, str]: + """Transform a template-space BOLD mask to native space.""" + num_cpus = cast( + int, cfg["pipeline_setup", "system_config", "max_cores_per_participant"] + ) + num_ants_cores = cast( + int, cfg["pipeline_setup", "system_config", "num_ants_threads"] + ) + apply_xfm = apply_transform( + f"xfm_from-template_to-bold_mask_{pipe_num}", + reg_tool, + time_series=True, + num_cpus=num_cpus, + num_ants_cores=num_ants_cores, + ) + apply_xfm.inputs.inputspec.interpolation = cfg[ + "registration_workflows", + "functional_registration", + "func_registration_to_template", + f"{'ANTs' if reg_tool == 'ants' else 'FNIRT'}_pipelines", + "interpolation", + ] + sbref = strat_pool.node_data("sbref") + bold_mask = strat_pool.node_data( + ["space-template_desc-bold_mask", "space-template_desc-brain_mask"] + ) + xfm = strat_pool.node_data("from-template_to-bold_mode-image_xfm") + wf.connect( + [ + (bold_mask.node, apply_xfm, [(bold_mask.out, "inputspec.input_image")]), + (sbref.node, apply_xfm, [(sbref.out, "inputspec.reference")]), + (xfm.node, apply_xfm, [(xfm.out, "inputspec.transform")]), + ] + ) + + return apply_xfm, "outputspec.output_image" diff --git a/CPAC/pipeline/__init__.py b/CPAC/pipeline/__init__.py index 6002aa8b97..1e28692e73 100644 --- a/CPAC/pipeline/__init__.py +++ b/CPAC/pipeline/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2022 - 2024 C-PAC Developers +# Copyright (C) 2022 - 2025 C-PAC Developers # This file is part of C-PAC. @@ -16,20 +16,14 @@ # License along with C-PAC. If not, see . """The C-PAC pipeline and its underlying infrastructure.""" -import os - -import pkg_resources as p - from CPAC.pipeline.nipype_pipeline_engine.monkeypatch import patch_base_interface +from CPAC.resources.configs import CONFIGS_PATH patch_base_interface() # Monkeypatch Nipypes BaseInterface class -ALL_PIPELINE_CONFIGS = os.listdir( - p.resource_filename("CPAC", os.path.join("resources", "configs")) -) ALL_PIPELINE_CONFIGS = [ x.split("_")[2].replace(".yml", "") - for x in ALL_PIPELINE_CONFIGS + for x in [str(_) for _ in CONFIGS_PATH.iterdir()] if "pipeline_config" in x ] ALL_PIPELINE_CONFIGS.sort() diff --git a/CPAC/pipeline/check_outputs.py b/CPAC/pipeline/check_outputs.py index 2e55ef560d..7db2349337 100644 --- a/CPAC/pipeline/check_outputs.py +++ b/CPAC/pipeline/check_outputs.py @@ -59,7 +59,11 @@ def check_outputs(output_dir: str, log_dir: str, pipe_name: str, unique_id: str) if isinstance(outputs_logger, (Logger, MockLogger)) and len( outputs_logger.handlers ): - outputs_log = getattr(outputs_logger.handlers[0], "baseFilename", None) + outputs_log = getattr( + MockLogger._get_first_file_handler(outputs_logger.handlers), + "baseFilename", + None, + ) else: outputs_log = None if outputs_log is None: @@ -103,7 +107,7 @@ def check_outputs(output_dir: str, log_dir: str, pipe_name: str, unique_id: str) try: log_note = ( "Missing outputs have been logged in " - f"{missing_log.handlers[0].baseFilename}" + f"{MockLogger._get_first_file_handler(missing_log.handlers).baseFilename}" ) except (AttributeError, IndexError): log_note = "" diff --git a/CPAC/pipeline/cpac_group_runner.py b/CPAC/pipeline/cpac_group_runner.py index 57d5cc80dc..acc594adaf 100644 --- a/CPAC/pipeline/cpac_group_runner.py +++ b/CPAC/pipeline/cpac_group_runner.py @@ -1,4 +1,4 @@ -# Copyright (C) 2022-2024 C-PAC Developers +# Copyright (C) 2022-2025 C-PAC Developers # This file is part of C-PAC. @@ -143,31 +143,12 @@ def gather_nifti_globs(pipeline_output_folder, resource_list, pull_func=False): import glob import os - import pandas as pd - import pkg_resources as p + from CPAC.utils.outputs import group_derivatives exts = ".nii" nifti_globs = [] - keys_tsv = p.resource_filename("CPAC", "resources/cpac_outputs.tsv") - try: - keys = pd.read_csv(keys_tsv, delimiter="\t") - except Exception as e: - err = ( - "\n[!] Could not access or read the cpac_outputs.tsv " - f"resource file:\n{keys_tsv}\n\nError details {e}\n" - ) - raise Exception(err) - - derivative_list = list(keys[keys["Sub-Directory"] == "func"]["Resource"]) - derivative_list = derivative_list + list( - keys[keys["Sub-Directory"] == "anat"]["Resource"] - ) - - if pull_func: - derivative_list = derivative_list + list( - keys[keys["Space"] == "functional"]["Resource"] - ) + derivative_list = group_derivatives(pull_func) if len(resource_list) == 0: err = "\n\n[!] No derivatives selected!\n\n" @@ -361,33 +342,14 @@ def create_output_dict_list( """Create a dictionary of output filepaths and their associated information.""" import os - import pandas as pd - import pkg_resources as p - if len(resource_list) == 0: err = "\n\n[!] No derivatives selected!\n\n" raise Exception(err) if derivatives is None: - keys_tsv = p.resource_filename("CPAC", "resources/cpac_outputs.tsv") - try: - keys = pd.read_csv(keys_tsv, delimiter="\t") - except Exception as e: - err = ( - "\n[!] Could not access or read the cpac_outputs.csv " - f"resource file:\n{keys_tsv}\n\nError details {e}\n" - ) - raise Exception(err) + from CPAC.utils.outputs import group_derivatives - derivatives = list(keys[keys["Sub-Directory"] == "func"]["Resource"]) - derivatives = derivatives + list( - keys[keys["Sub-Directory"] == "anat"]["Resource"] - ) - - if pull_func: - derivatives = derivatives + list( - keys[keys["Space"] == "functional"]["Resource"] - ) + derivatives = group_derivatives(pull_func) # remove any extra /'s pipeline_output_folder = pipeline_output_folder.rstrip("/") @@ -752,18 +714,10 @@ def prep_feat_inputs(group_config_file: str) -> dict: import os import pandas as pd - import pkg_resources as p - keys_tsv = p.resource_filename("CPAC", "resources/cpac_outputs.tsv") - try: - keys = pd.read_csv(keys_tsv, delimiter="\t") - except Exception as e: - err = ( - "\n[!] Could not access or read the cpac_outputs.tsv " - f"resource file:\n{keys_tsv}\n\nError details {e}\n" - ) - raise Exception(err) + from CPAC.utils.outputs import Outputs + keys = Outputs.reference derivatives = list( keys[keys["Derivative"] == "yes"][keys["Space"] == "template"][ keys["Values"] == "z-score" diff --git a/CPAC/pipeline/cpac_pipeline.py b/CPAC/pipeline/cpac_pipeline.py index 40811b9e77..ec5339a188 100644 --- a/CPAC/pipeline/cpac_pipeline.py +++ b/CPAC/pipeline/cpac_pipeline.py @@ -1,4 +1,4 @@ -# Copyright (C) 2012-2024 C-PAC Developers +# Copyright (C) 2012-2025 C-PAC Developers # This file is part of C-PAC. @@ -25,6 +25,7 @@ import sys import time from time import strftime +from typing import TYPE_CHECKING import yaml import nipype @@ -51,18 +52,12 @@ brain_extraction_temp_T2, brain_mask_acpc_afni, brain_mask_acpc_freesurfer, - brain_mask_acpc_freesurfer_abcd, - brain_mask_acpc_freesurfer_fsl_loose, - brain_mask_acpc_freesurfer_fsl_tight, brain_mask_acpc_fsl, brain_mask_acpc_niworkflows_ants, brain_mask_acpc_T2, brain_mask_acpc_unet, brain_mask_afni, brain_mask_freesurfer, - brain_mask_freesurfer_abcd, - brain_mask_freesurfer_fsl_loose, - brain_mask_freesurfer_fsl_tight, brain_mask_fsl, brain_mask_niworkflows_ants, brain_mask_T2, @@ -83,12 +78,7 @@ distcor_phasediff_fsl_fugue, ) from CPAC.func_preproc import ( - calc_motion_stats, - func_motion_correct, - func_motion_correct_only, - func_motion_estimates, - get_motion_ref, - motion_estimate_filter, + stack_motion_blocks, ) from CPAC.func_preproc.func_preproc import ( bold_mask_afni, @@ -107,6 +97,7 @@ func_scaling, func_slice_time, func_truncate, + template_space_bold_masking, ) from CPAC.network_centrality.pipeline import network_centrality from CPAC.nuisance.nuisance import ( @@ -148,6 +139,7 @@ coregistration_prep_vol, create_func_to_T1template_symmetric_xfm, create_func_to_T1template_xfm, + mask_sbref, overwrite_transform_anat_to_template, register_ANTs_anat_to_template, register_ANTs_EPI_to_template, @@ -215,6 +207,9 @@ from CPAC.utils.workflow_serialization import cpac_flowdump_serializer from CPAC.vmhc.vmhc import smooth_func_vmhc, vmhc, warp_timeseries_to_sym_template +if TYPE_CHECKING: + from CPAC.pipeline.nodeblock import NodeBlockFunction + faulthandler.enable() # config.enable_debug_mode() @@ -419,7 +414,7 @@ def run_workflow( ) if c.pipeline_setup["system_config"]["random_seed"] is not None else "", - license_notice=CPAC.license_notice.replace("\n", "\n "), + license_notice=CPAC.license_notice().replace("\n", "\n "), ), ) subject_info = {} @@ -710,21 +705,24 @@ def run_workflow( ] timeHeader = dict(zip(gpaTimeFields, gpaTimeFields)) - with open( - os.path.join( - c.pipeline_setup["log_directory"]["path"], - "cpac_individual_timing" - f"_{c.pipeline_setup['pipeline_name']}.csv", - ), - "a", - ) as timeCSV, open( - os.path.join( - c.pipeline_setup["log_directory"]["path"], - "cpac_individual_timing_%s.csv" - % c.pipeline_setup["pipeline_name"], - ), - "r", - ) as readTimeCSV: + with ( + open( + os.path.join( + c.pipeline_setup["log_directory"]["path"], + "cpac_individual_timing" + f"_{c.pipeline_setup['pipeline_name']}.csv", + ), + "a", + ) as timeCSV, + open( + os.path.join( + c.pipeline_setup["log_directory"]["path"], + "cpac_individual_timing_%s.csv" + % c.pipeline_setup["pipeline_name"], + ), + "r", + ) as readTimeCSV, + ): timeWriter = csv.DictWriter(timeCSV, fieldnames=gpaTimeFields) timeReader = csv.DictReader(readTimeCSV) @@ -929,10 +927,7 @@ def build_anat_preproc_stack(rpool, cfg, pipeline_blocks=None): brain_mask_acpc_fsl, brain_mask_acpc_niworkflows_ants, brain_mask_acpc_unet, - brain_mask_acpc_freesurfer_abcd, brain_mask_acpc_freesurfer, - brain_mask_acpc_freesurfer_fsl_tight, - brain_mask_acpc_freesurfer_fsl_loose, ], acpc_align_brain_with_mask, brain_extraction_temp, @@ -982,10 +977,7 @@ def build_anat_preproc_stack(rpool, cfg, pipeline_blocks=None): brain_mask_fsl, brain_mask_niworkflows_ants, brain_mask_unet, - brain_mask_freesurfer_abcd, brain_mask_freesurfer, - brain_mask_freesurfer_fsl_tight, - brain_mask_freesurfer_fsl_loose, ] ] pipeline_blocks += anat_brain_mask_blocks @@ -1256,35 +1248,35 @@ def build_workflow(subject_id, sub_dict, cfg, pipeline_name=None): # Functional Preprocessing, including motion correction and BOLD masking if cfg.functional_preproc["run"]: - func_init_blocks = [func_reorient, func_scaling, func_truncate] - func_preproc_blocks = [func_despike, func_slice_time] + func_blocks: dict[str, list[NodeBlockFunction | list[NodeBlockFunction]]] = {} + func_blocks["init"] = [func_reorient, func_scaling, func_truncate] + func_blocks["preproc"] = [func_despike, func_slice_time] if not rpool.check_rpool("desc-mean_bold"): - func_preproc_blocks.append(func_mean) + func_blocks["preproc"].append(func_mean) - func_mask_blocks = [] + func_blocks["mask"] = [] if not rpool.check_rpool("space-bold_desc-brain_mask"): - func_mask_blocks = [ + func_blocks["mask"] = [ [ bold_mask_afni, bold_mask_fsl, bold_mask_fsl_afni, bold_mask_anatomical_refined, bold_mask_anatomical_based, - bold_mask_anatomical_resampled, bold_mask_ccs, ], bold_masking, ] - func_prep_blocks = [ - calc_motion_stats, + func_blocks["prep"] = [ func_normalize, [ coregistration_prep_vol, coregistration_prep_mean, coregistration_prep_fmriprep, ], + mask_sbref, ] # Distortion/Susceptibility Correction @@ -1305,49 +1297,16 @@ def build_workflow(subject_id, sub_dict, cfg, pipeline_name=None): if distcor_blocks: if len(distcor_blocks) > 1: distcor_blocks = [distcor_blocks] - func_prep_blocks += distcor_blocks - - func_motion_blocks = [] - if not rpool.check_rpool("desc-movementParameters_motion"): - if cfg["functional_preproc"]["motion_estimates_and_correction"][ - "motion_estimates" - ]["calculate_motion_first"]: - func_motion_blocks = [ - get_motion_ref, - func_motion_estimates, - motion_estimate_filter, - ] - func_blocks = ( - func_init_blocks - + func_motion_blocks - + func_preproc_blocks - + [func_motion_correct_only] - + func_mask_blocks - + func_prep_blocks - ) - else: - func_motion_blocks = [ - get_motion_ref, - func_motion_correct, - motion_estimate_filter, - ] - func_blocks = ( - func_init_blocks - + func_preproc_blocks - + func_motion_blocks - + func_mask_blocks - + func_prep_blocks - ) - else: - func_blocks = ( - func_init_blocks - + func_preproc_blocks - + func_motion_blocks - + func_mask_blocks - + func_prep_blocks - ) + func_blocks["prep"] += distcor_blocks - pipeline_blocks += func_blocks + pipeline_blocks += stack_motion_blocks(func_blocks, cfg, rpool) + + # Template space functional mask + if cfg.functional_preproc["template_space_func_masking"]["run"]: + if not rpool.check_rpool("space-template_desc-bold_mask"): + pipeline_blocks += [ + bold_mask_anatomical_resampled, + ] # BOLD to T1 coregistration if cfg.registration_workflows["functional_registration"]["coregistration"][ @@ -1508,6 +1467,12 @@ def build_workflow(subject_id, sub_dict, cfg, pipeline_name=None): warp_deriv_mask_to_EPItemplate, ] + # Apply mask in template space + if cfg.functional_preproc["template_space_func_masking"]["run"]: + pipeline_blocks += [ + template_space_bold_masking, + ] + # Template-space nuisance regression nuisance_template = ( cfg["nuisance_corrections", "2-nuisance_regression", "space"] == "template" diff --git a/CPAC/pipeline/cpac_runner.py b/CPAC/pipeline/cpac_runner.py index 0110281d5d..cbe121fba0 100644 --- a/CPAC/pipeline/cpac_runner.py +++ b/CPAC/pipeline/cpac_runner.py @@ -1,4 +1,4 @@ -# Copyright (C) 2022-2024 C-PAC Developers +# Copyright (C) 2022-2025 C-PAC Developers # This file is part of C-PAC. @@ -14,32 +14,37 @@ # You should have received a copy of the GNU Lesser General Public # License along with C-PAC. If not, see . +"""Run C-PAC pipeline as configured.""" + from multiprocessing import Process import os from time import strftime +from typing import Optional import warnings from voluptuous.error import Invalid import yaml +from nipype.pipeline.plugins.base import PluginBase as Plugin from CPAC.longitudinal_pipeline.longitudinal_workflow import anat_longitudinal_wf +from CPAC.pipeline.utils import get_shell from CPAC.utils.configuration import check_pname, Configuration, set_subject from CPAC.utils.configuration.yaml_template import upgrade_pipeline_to_1_8 from CPAC.utils.ga import track_run -from CPAC.utils.monitoring import failed_to_start, log_nodes_cb, WFLOGGER +from CPAC.utils.io import load_yaml +from CPAC.utils.monitoring import failed_to_start, FMLOGGER, log_nodes_cb, WFLOGGER + +RECOMMENDED_MAX_PATH_LENGTH: int = 70 +"""Recommended maximum length for a working directory path.""" -# Run condor jobs def run_condor_jobs(c, config_file, subject_list_file, p_name): + """Run condor jobs.""" # Import packages import subprocess from time import strftime - try: - sublist = yaml.safe_load(open(os.path.realpath(subject_list_file), "r")) - except: - msg = "Subject list is not in proper YAML format. Please check your file" - raise Exception(msg) + sublist = load_yaml(subject_list_file, "Subject list") cluster_files_dir = os.path.join(os.getcwd(), "cluster_files") subject_bash_file = os.path.join( @@ -100,9 +105,9 @@ def run_condor_jobs(c, config_file, subject_list_file, p_name): # Create and run script for CPAC to run on cluster def run_cpac_on_cluster(config_file, subject_list_file, cluster_files_dir): - """ - Function to build a SLURM batch job submission script and - submit it to the scheduler via 'sbatch'. + """Build a SLURM batch job submission script. + + Submit it to the scheduler via 'sbatch'. """ # Import packages import getpass @@ -113,18 +118,11 @@ def run_cpac_on_cluster(config_file, subject_list_file, cluster_files_dir): from indi_schedulers import cluster_templates # Load in pipeline config - try: - pipeline_dict = yaml.safe_load(open(os.path.realpath(config_file), "r")) - pipeline_config = Configuration(pipeline_dict) - except: - msg = "Pipeline config is not in proper YAML format. Please check your file" - raise Exception(msg) + pipeline_dict = load_yaml(config_file, "Pipeline config") + pipeline_config = Configuration(pipeline_dict) + # Load in the subject list - try: - sublist = yaml.safe_load(open(os.path.realpath(subject_list_file), "r")) - except: - msg = "Subject list is not in proper YAML format. Please check your file" - raise Exception(msg) + sublist = load_yaml(subject_list_file, "Subject list") # Init variables timestamp = str(strftime("%Y_%m_%d_%H_%M_%S")) @@ -137,7 +135,6 @@ def run_cpac_on_cluster(config_file, subject_list_file, cluster_files_dir): time_limit = "%d:00:00" % hrs_limit # Batch file variables - shell = subprocess.getoutput("echo $SHELL") user_account = getpass.getuser() num_subs = len(sublist) @@ -174,7 +171,7 @@ def run_cpac_on_cluster(config_file, subject_list_file, cluster_files_dir): # Set up config dictionary config_dict = { "timestamp": timestamp, - "shell": shell, + "shell": get_shell(), "job_name": "CPAC_" + pipeline_config.pipeline_setup["pipeline_name"], "num_tasks": num_subs, "queue": pipeline_config.pipeline_setup["system_config"]["on_grid"]["SGE"][ @@ -238,6 +235,7 @@ def run_cpac_on_cluster(config_file, subject_list_file, cluster_files_dir): def run_T1w_longitudinal(sublist, cfg): + """Run anatomical longitudinal pipeline.""" subject_id_dict = {} for sub in sublist: @@ -260,16 +258,16 @@ def run_T1w_longitudinal(sublist, cfg): ) -def run( - subject_list_file, - config_file=None, - p_name=None, - plugin=None, - plugin_args=None, - tracking=True, - num_subs_at_once=None, - debug=False, - test_config=False, +def run( # noqa: PLR0915 + subject_list_file: str, + config_file: Optional[str] = None, + p_name: Optional[str] = None, + plugin: Optional[str | Plugin] = None, + plugin_args: Optional[dict] = None, + tracking: bool = True, + num_subs_at_once: Optional[int] = None, + debug: bool = False, + test_config: bool = False, ) -> int: """Run C-PAC subjects via job queue. @@ -292,11 +290,9 @@ def run( plugin_args = {"status_callback": log_nodes_cb} if not config_file: - import pkg_resources as p + from CPAC.resources.configs import CONFIGS_PATH - config_file = p.resource_filename( - "CPAC", os.path.join("resources", "configs", "pipeline_config_template.yml") - ) + config_file = str(CONFIGS_PATH / "pipeline_config_template.yml") # Init variables sublist = None @@ -322,22 +318,21 @@ def run( config_file = os.path.realpath(config_file) try: if not os.path.exists(config_file): - raise IOError - else: + raise FileNotFoundError(config_file) + try: + c = Configuration(load_yaml(config_file, "Pipeline configuration")) + except Invalid: try: - c = Configuration(yaml.safe_load(open(config_file, "r"))) - except Invalid: - try: - upgrade_pipeline_to_1_8(config_file) - c = Configuration(yaml.safe_load(open(config_file, "r"))) - except Exception as e: - msg = ( - "C-PAC could not upgrade pipeline configuration file " - f"{config_file} to v1.8 syntax" - ) - raise RuntimeError(msg) from e + upgrade_pipeline_to_1_8(config_file) + c = Configuration(load_yaml(config_file, "Pipeline configuration")) except Exception as e: - raise e + msg = ( + "C-PAC could not upgrade pipeline configuration file " + f"{config_file} to v1.8 syntax" + ) + raise RuntimeError(msg) from e + except Exception as e: + raise e except IOError as e: msg = f"config file {config_file} doesn't exist" raise FileNotFoundError(msg) from e @@ -385,10 +380,10 @@ def run( msg = "Working directory not specified" raise Exception(msg) - if len(c.pipeline_setup["working_directory"]["path"]) > 70: + if len(c.pipeline_setup["working_directory"]["path"]) > RECOMMENDED_MAX_PATH_LENGTH: warnings.warn( "We recommend that the working directory full path " - "should have less then 70 characters. " + f"should have less then {RECOMMENDED_MAX_PATH_LENGTH} characters. " "Long paths might not work in your operating system." ) warnings.warn( @@ -400,12 +395,8 @@ def run( p_name = check_pname(p_name, c) # Load in subject list - try: - if not sublist: - sublist = yaml.safe_load(open(subject_list_file, "r")) - except: - msg = "Subject list is not in proper YAML format. Please check your file" - raise FileNotFoundError(msg) + if not sublist: + sublist = load_yaml(subject_list_file, "Subject list") # Populate subject scan map sub_scan_map = {} @@ -418,12 +409,12 @@ def run( scan_ids = ["scan_anat"] if "func" in sub: - for id in sub["func"]: - scan_ids.append("scan_" + str(id)) + for _id in sub["func"]: + scan_ids.append("scan_" + str(_id)) if "rest" in sub: - for id in sub["rest"]: - scan_ids.append("scan_" + str(id)) + for _id in sub["rest"]: + scan_ids.append("scan_" + str(_id)) sub_scan_map[s] = scan_ids except Exception as e: @@ -444,8 +435,10 @@ def run( level="participant" if not test_config else "test", participants=len(sublist), ) - except: - WFLOGGER.error("Usage tracking failed for this run.") + except Exception as exception: + WFLOGGER.error( + "Usage tracking failed for this run.\nDetails: %s", exception + ) # If we're running on cluster, execute job scheduler if c.pipeline_setup["system_config"]["on_grid"]["run"]: @@ -471,15 +464,20 @@ def run( # Create working dir if not os.path.exists(c.pipeline_setup["working_directory"]["path"]): try: - os.makedirs(c.pipeline_setup["working_directory"]["path"]) - except: + os.makedirs( + c.pipeline_setup["working_directory"]["path"], exist_ok=True + ) + except FileExistsError: + FMLOGGER.warn( + f"Path exists: {c['pipeline_setup', 'working_directory', 'path']}" + ) + except Exception as exception: err = ( - "\n\n[!] CPAC says: Could not create the working " - "directory: %s\n\nMake sure you have permissions " - "to write to this directory.\n\n" - % c.pipeline_setup["working_directory"]["path"] + "\n\n[!] CPAC says: Could not create the working directory: " + f"{c['pipeline_setup', 'working_directory', 'path']}\n\nMake sure " + "you have permissions to write to this directory.\n\n" ) - raise Exception(err) + raise IOError(err) from exception """ if not os.path.exists(c.pipeline_setup['log_directory']['path']): try: diff --git a/CPAC/pipeline/engine.py b/CPAC/pipeline/engine.py index d7f53f7029..415307c751 100644 --- a/CPAC/pipeline/engine.py +++ b/CPAC/pipeline/engine.py @@ -1,4 +1,4 @@ -# Copyright (C) 2021-2024 C-PAC Developers +# Copyright (C) 2021-2025 C-PAC Developers # This file is part of C-PAC. @@ -14,19 +14,25 @@ # You should have received a copy of the GNU Lesser General Public # License along with C-PAC. If not, see . +"""C-PAC pipeline engine.""" + import ast import copy import hashlib +from importlib.resources import files from itertools import chain import json import os import re -from typing import Optional +from typing import Callable, Generator, Literal, Optional import warnings +import pandas as pd from nipype import config, logging +from nipype.interfaces import afni from nipype.interfaces.utility import Rename +from CPAC.anat_preproc.utils import mri_convert_reorient from CPAC.image_utils.spatial_smoothing import spatial_smoothing from CPAC.image_utils.statistical_transforms import ( fisher_z_score_standardize, @@ -35,7 +41,15 @@ from CPAC.pipeline import nipype_pipeline_engine as pe from CPAC.pipeline.check_outputs import ExpectedOutputs from CPAC.pipeline.nodeblock import NodeBlockFunction -from CPAC.pipeline.utils import MOVEMENT_FILTER_KEYS, name_fork, source_set +from CPAC.pipeline.utils import ( + CrossedVariantsError, + find_variants, + MOVEMENT_FILTER_KEYS, + name_fork, + short_circuit_crossed_variants, + source_set, + validate_outputs, +) from CPAC.registration.registration import transform_derivative from CPAC.resources.templates.lookup_table import lookup_identifier from CPAC.utils.bids_utils import res_in_filename @@ -59,6 +73,7 @@ from CPAC.utils.utils import ( check_prov_for_regtool, create_id_string, + flip_orientation_code, get_last_prov_entry, read_json, write_output_json, @@ -298,7 +313,7 @@ def regressor_dct(self, cfg) -> dict: "ingress_regressors." ) _nr = cfg["nuisance_corrections", "2-nuisance_regression"] - if not hasattr(self, "timeseries"): + if not hasattr(self, "desc-confounds_timeseries"): if _nr["Regressors"]: self.regressors = {reg["Name"]: reg for reg in _nr["Regressors"]} else: @@ -413,10 +428,12 @@ def get( if report_fetched: return (None, None) return None + from CPAC.pipeline.resource_inventory import where_to_find + msg = ( "\n\n[!] C-PAC says: None of the listed resources are in " - f"the resource pool:\n\n {resource}\n\nOptions:\n- You " - "can enable a node block earlier in the pipeline which " + f"the resource pool:\n\n {where_to_find(resource)}\n\nOptions:\n" + "- You can enable a node block earlier in the pipeline which " "produces these resources. Check the 'outputs:' field in " "a node block's documentation.\n- You can directly " "provide this required data by pulling it from another " @@ -451,7 +468,9 @@ def copy_resource(self, resource, new_name): try: self.rpool[new_name] = self.rpool[resource] except KeyError: - msg = f"[!] {resource} not in the resource pool." + from CPAC.pipeline.resource_inventory import where_to_find + + msg = f"[!] Not in the resource pool:\n{where_to_find(resource)}" raise Exception(msg) def update_resource(self, resource, new_name): @@ -495,6 +514,34 @@ def get_cpac_provenance(self, resource, strat=None): json_data = self.get_json(resource, strat) return json_data["CpacProvenance"] + def motion_tool(self, resource: str, strat=None) -> Literal["3dvolreg", "mcflirt"]: + """Check provenance for motion correction tool.""" + for tool in ["3dvolreg", "mcflirt"]: + if self.check_rpool(f"motion-correct-{tool}"): + return tool + prov = self.get_cpac_provenance(resource, strat) + last_entry = get_last_prov_entry(prov) + last_node = last_entry.split(":")[1] + if "3dvolreg" in last_node.lower(): + return "3dvolreg" + if "mcflirt" in last_node.lower(): + return "mcflirt" + # check entire prov + if "3dvolreg" in str(prov): + return "3dvolreg" + if "mcflirt" in str(prov): + return "mcflirt" + msg = ( + "\n[!] Developer info: the motion correction " + f"tool for {resource} is not in the " + "CpacProvenance.\n" + ) + raise LookupError(msg) + + def reg_tool(self, resource, strat=None) -> Optional[Literal["ants", "fsl"]]: + """Check provenance for registration tool.""" + return check_prov_for_regtool(self.get_cpac_provenance(resource, strat)) + @staticmethod def generate_prov_string(prov): # this will generate a string from a SINGLE RESOURCE'S dictionary of @@ -554,12 +601,10 @@ def flatten_prov(self, prov): return flat_prov return None - def get_strats(self, resources, debug=False): + def get_strats(self, resources, debug: bool | str = False): # TODO: NOTE: NOT COMPATIBLE WITH SUB-RPOOL/STRAT_POOLS # TODO: (and it doesn't have to be) - import itertools - linked_resources = [] resource_list = [] if debug: @@ -623,11 +668,13 @@ def get_strats(self, resources, debug=False): total_pool.append(sub_pool) if not total_pool: + from CPAC.pipeline.resource_inventory import where_to_find + raise LookupError( "\n\n[!] C-PAC says: None of the listed " "resources in the node block being connected " "exist in the resource pool.\n\nResources:\n" - "%s\n\n" % resource_list + "%s\n\n" % where_to_find(resource_list) ) # TODO: right now total_pool is: @@ -638,7 +685,7 @@ def get_strats(self, resources, debug=False): # TODO: and the actual resource is encoded in the tag: of the last item, every time! # keying the strategies to the resources, inverting it if len_inputs > 1: - strats = itertools.product(*total_pool) + strats = self.linked_product(total_pool, linked_resources, self.get_json) # we now currently have "strats", the combined permutations of all the strategies, as a list of tuples, each tuple combining one version of input each, being one of the permutations. # OF ALL THE DIFFERENT INPUTS. and they are tagged by their fetched inputs with {name}:{strat}. @@ -658,6 +705,7 @@ def get_strats(self, resources, debug=False): if debug: verbose_logger = getLogger("CPAC.engine") verbose_logger.debug("len(strat_list_list): %s\n", len(strat_list_list)) + for strat_list in strat_list_list: json_dct = {} for strat in strat_list: @@ -804,7 +852,27 @@ def get_strats(self, resources, debug=False): new_strats[pipe_idx].rpool["json"]["subjson"][data_type].update( copy.deepcopy(resource_strat_dct["json"]) ) - return new_strats + return_strats: dict[str, ResourcePool] = {} + for pipe_idx, strat_pool in new_strats.items(): + try: + short_circuit_crossed_variants(strat_pool, resources) + return_strats[pipe_idx] = strat_pool + except CrossedVariantsError: + if debug: + verbose_logger = getLogger("CPAC.engine") + verbose_logger.debug( + "Dropped crossed variants strat: %s", + find_variants(strat_pool, resources), + ) + continue + if debug: + verbose_logger = getLogger("CPAC.engine") + _k = list(return_strats.keys()) + if isinstance(debug, str): + verbose_logger.debug("return_strats: (%s, %s) %s\n", debug, len(_k), _k) + else: + verbose_logger.debug("return_strats: (%s) %s\n", len(_k), _k) + return return_strats def derivative_xfm(self, wf, label, connection, json_info, pipe_idx, pipe_x): if label in self.xfm: @@ -1002,6 +1070,19 @@ def post_process(self, wf, label, connection, json_info, pipe_idx, pipe_x, outs) for label_con_tpl in post_labels: label = label_con_tpl[0] connection = (label_con_tpl[1], label_con_tpl[2]) + if "desc-" not in label: + if "space-template" in label: + new_label = label.replace( + "space-template", "space-template_desc-zstd" + ) + else: + new_label = f"desc-zstd_{label}" + else: + for tag in label.split("_"): + if "desc-" in tag: + newtag = f"{tag}-zstd" + new_label = label.replace(tag, newtag) + break if label in Outputs.to_zstd: zstd = z_score_standardize(f"{label}_zstd_{pipe_x}", input_type) @@ -1010,20 +1091,6 @@ def post_process(self, wf, label, connection, json_info, pipe_idx, pipe_x, outs) node, out = self.get_data(mask, pipe_idx=mask_idx) wf.connect(node, out, zstd, "inputspec.mask") - if "desc-" not in label: - if "space-template" in label: - new_label = label.replace( - "space-template", "space-template_desc-zstd" - ) - else: - new_label = f"desc-zstd_{label}" - else: - for tag in label.split("_"): - if "desc-" in tag: - newtag = f"{tag}-zstd" - new_label = label.replace(tag, newtag) - break - post_labels.append((new_label, zstd, "outputspec.out_file")) self.set_data( @@ -1183,7 +1250,7 @@ def gather_pipes(self, wf, cfg, all=False, add_incl=None, add_excl=None): key for json_info in all_jsons for key in json_info.get("CpacVariant", {}).keys() - if key not in (*MOVEMENT_FILTER_KEYS, "regressors") + if key not in (*MOVEMENT_FILTER_KEYS, "timeseries") } if "bold" in unlabelled: all_bolds = list( @@ -1352,6 +1419,9 @@ def gather_pipes(self, wf, cfg, all=False, add_incl=None, add_excl=None): wf.connect(id_string, "out_filename", nii_name, "format_string") node, out = self.rpool[resource][pipe_idx]["data"] + if not node: + msg = f"Resource {resource} not found in resource pool." + raise FileNotFoundError(msg) try: wf.connect(node, out, nii_name, "in_file") except OSError as os_error: @@ -1394,7 +1464,37 @@ def gather_pipes(self, wf, cfg, all=False, add_incl=None, add_excl=None): subdir=out_dct["subdir"], ), ) - wf.connect(nii_name, "out_file", ds, f'{out_dct["subdir"]}.@data') + if resource.endswith("_bold"): + # Node to validate TR (and other scan parameters) + validate_bold_header = pe.Node( + Function( + input_names=["input_bold", "RawSource_bold"], + output_names=["output_bold"], + function=validate_outputs, + imports=[ + "from CPAC.pipeline.utils import find_pixdim4, update_pixdim4" + ], + ), + name=f"validate_bold_header_{resource_idx}_{pipe_x}", + ) + raw_source, raw_out = self.get_data("bold") + wf.connect( + [ + (nii_name, validate_bold_header, [(out, "input_bold")]), + ( + raw_source, + validate_bold_header, + [(raw_out, "RawSource_bold")], + ), + ( + validate_bold_header, + ds, + [("output_bold", f'{out_dct["subdir"]}.@data')], + ), + ] + ) + else: + wf.connect(nii_name, "out_file", ds, f'{out_dct["subdir"]}.@data') wf.connect(write_json, "json_file", ds, f'{out_dct["subdir"]}.@json') outputs_logger.info(expected_outputs) @@ -1411,6 +1511,128 @@ def node_data(self, resource, **kwargs): """ return NodeData(self, resource, **kwargs) + @staticmethod + def _normalize_variant_dict(json_obj: dict) -> dict[str, Optional[str]]: + """ + Return {variant_key: primary_value or None}. + + - list items are concatentated + - "NO-..." entries normalize to None + """ + out = {} + for k, v in json_obj.get("CpacVariant", {}).items(): + assert isinstance(v, (list, str)) + primary = "-".join(v) if isinstance(v, list) else v + out[k] = ( + None + if (isinstance(primary, str) and primary.startswith("NO-")) + else primary + ) + return out + + def _is_consistent( + self, + strat_list: list, + linked_resources: list | tuple, + json_lookup: Callable[[str, str | list[str]], dict], + debug: bool = False, + ) -> bool: + """ + Ensure consistency for linked_resources in strat_list. + + Rules: + - Sub-keys only compared if they exist in multiple resources in the same linked group. + - Missing sub-keys or NO-... values are compatible with anything. + - Lists are compared ignoring order. + - Debug prints a summary table per linked group. + """ + if not linked_resources: + return True + + # Build JSON for each prov + prov_json = {} + for prov in strat_list: + resource, strat_idx = self.generate_prov_string(prov) + prov_json[resource] = self._normalize_variant_dict( + json_lookup(resource, strat_idx) + ) + + for linked_group in linked_resources: + # Keep only resources present in strat_list + variants_map = {r: prov_json[r] for r in linked_group if r in prov_json} + if len(variants_map) < 2: + continue # nothing to compare yet + + # Determine which sub-keys are shared across multiple resources + subkey_counts = {} + for subdict in variants_map.values(): + for k in subdict.keys(): + subkey_counts[k] = subkey_counts.get(k, 0) + 1 + shared_subkeys = {k for k, count in subkey_counts.items() if count > 1} + + # Pairwise comparison only for shared sub-keys + resources = list(variants_map.keys()) + for i in range(len(resources)): + res_a = resources[i] + subdict_a = variants_map[res_a] + for j in range(i + 1, len(resources)): + res_b = resources[j] + subdict_b = variants_map[res_b] + + for subkey in shared_subkeys: + val_a = subdict_a.get(subkey) + val_b = subdict_b.get(subkey) + + # Skip if missing or NO-... + skip_a = ( + val_a is None + or ( + isinstance(val_a, list) + and all(str(v).startswith("NO-") for v in val_a) + ) + or (isinstance(val_a, str) and val_a.startswith("NO-")) + ) + skip_b = ( + val_b is None + or ( + isinstance(val_b, list) + and all(str(v).startswith("NO-") for v in val_b) + ) + or (isinstance(val_b, str) and val_b.startswith("NO-")) + ) + if skip_a or skip_b: + continue + + # Normalize lists + val_a_norm = sorted(val_a) if isinstance(val_a, list) else val_a + val_b_norm = sorted(val_b) if isinstance(val_b, list) else val_b + + if val_a_norm != val_b_norm: + return False + return True + + def linked_product( + self, + resource_pools: "list[ResourcePool]", + linked_resources: list[str], + json_lookup: Callable[[str, str | list[str]], dict], + ) -> Generator: + """ + Generate only consistent combinations of cpac_prov values across pools. + """ + + def backtrack(idx, current): + if idx == len(resource_pools): + yield list(current) + return + for prov in resource_pools[idx]: + current.append(prov) + if self._is_consistent(current, linked_resources, json_lookup): + yield from backtrack(idx + 1, current) + current.pop() + + yield from backtrack(0, []) + class NodeBlock: def __init__(self, node_block_functions, debug=False): @@ -1511,7 +1733,10 @@ def grab_tiered_dct(self, cfg, key_list): raise KeyError(msg) from ke return cfg_dct - def connect_block(self, wf, cfg, rpool): + def connect_block( + self, wf: pe.Workflow, cfg: Configuration, rpool: ResourcePool + ) -> pe.Workflow: + """Connect NodeBlock to a Workflow given a Configuration and ResourcePool.""" debug = cfg.pipeline_setup["Debugging"]["verbose"] all_opts = [] for name, block_dct in self.node_blocks.items(): @@ -1567,6 +1792,11 @@ def connect_block(self, wf, cfg, rpool): opts.append(option_val) else: # AND, if there are multiple option-val's (in a list) in the docstring, it gets iterated below in 'for opt in option' etc. AND THAT'S WHEN YOU HAVE TO DELINEATE WITHIN THE NODE BLOCK CODE!!! opts = [None] + if debug: + verbose_logger = getLogger("CPAC.engine") + verbose_logger.debug( + "[connect_block] opts resolved for %s: %s", name, opts + ) all_opts += opts sidecar_additions = { @@ -1661,7 +1891,7 @@ def connect_block(self, wf, cfg, rpool): for ( pipe_idx, strat_pool, # strat_pool is a ResourcePool like {'desc-preproc_T1w': { 'json': info, 'data': (node, out) }, 'desc-brain_mask': etc.} - ) in rpool.get_strats(inputs, debug).items(): + ) in rpool.get_strats(inputs, name if debug else False).items(): # keep in mind rpool.get_strats(inputs) = {pipe_idx1: {'desc-preproc_T1w': etc.}, pipe_idx2: {..} } fork = False in switch for opt in opts: # it's a dictionary of ResourcePools called strat_pools, except those sub-ResourcePools only have one level! no pipe_idx strat keys. @@ -1683,6 +1913,13 @@ def connect_block(self, wf, cfg, rpool): strat_pool.copy_resource(input_name, interface[0]) replaced_inputs.append(interface[0]) try: + if debug: + verbose_logger = getLogger("CPAC.engine") + verbose_logger.debug( + "Before block '%s', strat_pool contains: %s", + block_function.__name__, + list(strat_pool.rpool.keys()), + ) wf, outs = block_function(wf, cfg, strat_pool, pipe_x, opt) except IOError as e: # duplicate node WFLOGGER.warning(e) @@ -2015,7 +2252,6 @@ def ingress_freesurfer(wf, rpool, cfg, data_paths, unique_id, part_id, ses_id): "pipeline-fs_hemi-R_desc-surfaceMap_volume": "surf/rh.volume", "pipeline-fs_hemi-L_desc-surfaceMesh_white": "surf/lh.white", "pipeline-fs_hemi-R_desc-surfaceMesh_white": "surf/rh.white", - "pipeline-fs_xfm": "mri/transforms/talairach.lta", } for key, outfile in recon_outs.items(): @@ -2028,9 +2264,30 @@ def ingress_freesurfer(wf, rpool, cfg, data_paths, unique_id, part_id, ses_id): creds_path=data_paths["creds_path"], dl_dir=cfg.pipeline_setup["working_directory"]["path"], ) - rpool.set_data( - key, fs_ingress, "outputspec.data", {}, "", f"fs_{key}_ingress" - ) + # reorient *.mgz + if outfile.endswith(".mgz"): + reorient_mgz = pe.Node( + Function( + input_names=["in_file", "orientation", "out_file"], + output_names=["out_file"], + function=mri_convert_reorient, + ), + name=f"reorient_mgz_{key}", + ) + # Flip orientation before reorient because mri_convert's orientation is opposite that of AFNI + reorient_mgz.inputs.orientation = flip_orientation_code( + cfg.pipeline_setup["desired_orientation"] + ) + reorient_mgz.inputs.out_file = None + wf.connect(fs_ingress, "outputspec.data", reorient_mgz, "in_file") + + rpool.set_data( + key, reorient_mgz, "out_file", {}, "", f"fs_{key}_ingress" + ) + else: + rpool.set_data( + key, fs_ingress, "outputspec.data", {}, "", f"fs_{key}_ingress" + ) else: warnings.warn( str(LookupError(f"\n[!] Path does not exist for {fullpath}.\n")) @@ -2376,7 +2633,7 @@ def func_outdir_ingress( def set_iterables(scan, mask_paths=None, ts_paths=None): - # match scan with filepath to get filepath + """Match scan with filepath to get filepath.""" mask_path = [path for path in mask_paths if scan in path] ts_path = [path for path in ts_paths if scan in path] @@ -2403,15 +2660,18 @@ def strip_template(data_label, dir_path, filename): return data_label, json -def ingress_pipeconfig_paths(cfg, rpool, unique_id, creds_path=None): +def template_dataframe() -> pd.DataFrame: + """Return the template dataframe.""" + template_csv = files("CPAC").joinpath("resources/cpac_templates.csv") + return pd.read_csv(str(template_csv), keep_default_na=False) + + +def ingress_pipeconfig_paths(wf, cfg, rpool, unique_id, creds_path=None): # ingress config file paths # TODO: may want to change the resource keys for each to include one level up in the YAML as well - import pandas as pd - import pkg_resources as p - - template_csv = p.resource_filename("CPAC", "resources/cpac_templates.csv") - template_df = pd.read_csv(template_csv, keep_default_na=False) + template_df = template_dataframe() + desired_orientation = cfg.pipeline_setup["desired_orientation"] for row in template_df.itertuples(): key = row.Key @@ -2468,7 +2728,13 @@ def ingress_pipeconfig_paths(cfg, rpool, unique_id, creds_path=None): resampled_template = pe.Node( Function( - input_names=["resolution", "template", "template_name", "tag"], + input_names=[ + "orientation", + "resolution", + "template", + "template_name", + "tag", + ], output_names=["resampled_template"], function=resolve_resolution, as_module=True, @@ -2476,24 +2742,15 @@ def ingress_pipeconfig_paths(cfg, rpool, unique_id, creds_path=None): name="resampled_" + key, ) + resampled_template.inputs.orientation = desired_orientation resampled_template.inputs.resolution = resolution resampled_template.inputs.template = val resampled_template.inputs.template_name = key resampled_template.inputs.tag = tag - # the set_data below is set up a little differently, because we are - # injecting and also over-writing already-existing entries - # other alternative would have been to ingress into the - # resampled_template node from the already existing entries, but we - # didn't do that here - rpool.set_data( - key, - resampled_template, - "resampled_template", - json_info, - "", - "template_resample", - ) # pipe_idx (after the blank json {}) should be the previous strat that you want deleted! because you're not connecting this the regular way, you have to do it manually + node = resampled_template + output = "resampled_template" + node_name = "template_resample" elif val: config_ingress = create_general_datasource(f"gather_{key}") @@ -2503,14 +2760,33 @@ def ingress_pipeconfig_paths(cfg, rpool, unique_id, creds_path=None): creds_path=creds_path, dl_dir=cfg.pipeline_setup["working_directory"]["path"], ) - rpool.set_data( - key, - config_ingress, - "outputspec.data", - json_info, - "", - f"{key}_config_ingress", - ) + node = config_ingress + output = "outputspec.data" + node_name = f"{key}_config_ingress" + + if val.endswith(".nii" or ".nii.gz"): + check_reorient = pe.Node( + interface=afni.Resample(), + name=f"reorient_{key}", + ) + + check_reorient.inputs.orientation = desired_orientation + check_reorient.inputs.outputtype = "NIFTI_GZ" + + wf.connect(node, output, check_reorient, "in_file") + node = check_reorient + output = "out_file" + node_name = f"{key}_reorient" + + rpool.set_data( + key, + node, + output, + json_info, + "", + node_name, + ) + # templates, resampling from config """ template_keys = [ @@ -2596,8 +2872,7 @@ def _set_nested(attr, keys): ) cfg.set_nested(cfg, key, node) """ - - return rpool + return wf, rpool def initiate_rpool(wf, cfg, data_paths=None, part_id=None): @@ -2668,7 +2943,7 @@ def initiate_rpool(wf, cfg, data_paths=None, part_id=None): ) # grab any file paths from the pipeline config YAML - rpool = ingress_pipeconfig_paths(cfg, rpool, unique_id, creds_path) + wf, rpool = ingress_pipeconfig_paths(wf, cfg, rpool, unique_id, creds_path) # output files with 4 different scans diff --git a/CPAC/pipeline/nipype_pipeline_engine/engine.py b/CPAC/pipeline/nipype_pipeline_engine/engine.py index 743285ae9d..94aacd123c 100644 --- a/CPAC/pipeline/nipype_pipeline_engine/engine.py +++ b/CPAC/pipeline/nipype_pipeline_engine/engine.py @@ -26,7 +26,7 @@ # Prior to release 0.12, Nipype was licensed under a BSD license. -# Modifications Copyright (C) 2022-2024 C-PAC Developers +# Modifications Copyright (C) 2022-2025 C-PAC Developers # This file is part of C-PAC. @@ -54,11 +54,11 @@ from inspect import Parameter, Signature, signature import os import re -from typing import Any, ClassVar, Optional +from typing import Any, ClassVar, Optional, TYPE_CHECKING from numpy import prod +from traits.api import List as TraitListObject from traits.trait_base import Undefined -from traits.trait_handlers import TraitListObject from nibabel import load from nipype.interfaces.utility import Function from nipype.pipeline import engine as pe @@ -76,6 +76,10 @@ from CPAC.utils.monitoring import getLogger, WFLOGGER +if TYPE_CHECKING: + from CPAC.pipeline.engine import ResourcePool + + # set global default mem_gb DEFAULT_MEM_GB = 2.0 UNDEFINED_SIZE = (42, 42, 42, 1200) @@ -762,6 +766,18 @@ def write_hierarchical_dotfile( else: WFLOGGER.info(dotstr) + def connect_optional( + self, + source_resource_pool: "ResourcePool", + source_resource: str | list[str], + dest: pe.Node, + dest_input: str, + ) -> None: + """Connect optional inputs to a workflow.""" + if source_resource_pool.check_rpool(source_resource): + node, out = source_resource_pool.get_data(source_resource) + self.connect(node, out, dest, dest_input) + def get_data_size(filepath, mode="xyzt"): """Return the size of a functional image (x * y * z * t). diff --git a/CPAC/pipeline/nodeblock.py b/CPAC/pipeline/nodeblock.py index 53b9db1330..90541ad08d 100644 --- a/CPAC/pipeline/nodeblock.py +++ b/CPAC/pipeline/nodeblock.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023-2024 C-PAC Developers +# Copyright (C) 2023-2025 C-PAC Developers # This file is part of C-PAC. @@ -16,7 +16,13 @@ # License along with C-PAC. If not, see . """Class and decorator for NodeBlock functions.""" -from typing import Any, Callable, Optional +from typing import Any, Callable, Mapping, Optional, TypeAlias + +from nipype.pipeline import engine as pe + +POOL_RESOURCE_DICT: TypeAlias = dict[str, tuple[pe.Node | pe.Workflow, str]] +POOL_RESOURCE_MAPPING: TypeAlias = Mapping[str, tuple[pe.Node | pe.Workflow, str]] +NODEBLOCK_RETURN: TypeAlias = tuple[pe.Workflow, POOL_RESOURCE_MAPPING] class NodeBlockFunction: @@ -77,9 +83,9 @@ def __init__( ] ).rstrip() - # all node block functions have this signature def __call__(self, wf, cfg, strat_pool, pipe_num, opt=None): """ + All node block functions have this signature. Parameters ---------- diff --git a/CPAC/pipeline/resource_inventory.py b/CPAC/pipeline/resource_inventory.py new file mode 100755 index 0000000000..a181ea6567 --- /dev/null +++ b/CPAC/pipeline/resource_inventory.py @@ -0,0 +1,670 @@ +#!/usr/bin/env python +# Copyright (C) 2025 C-PAC Developers + +# This file is part of C-PAC. + +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. + +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . +"""Inspect inputs and outputs for NodeBlockFunctions.""" + +from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser, Namespace +import ast +from collections.abc import Hashable +from dataclasses import dataclass, field +import importlib +from importlib.resources import files +import inspect +from itertools import chain, product +import os +from pathlib import Path +import re +from typing import Any, cast, Iterable, Optional +from unittest.mock import patch + +from traits.trait_errors import TraitError +import yaml + +from CPAC.pipeline.engine import template_dataframe +from CPAC.pipeline.nodeblock import NodeBlockFunction +from CPAC.pipeline.schema import latest_schema +from CPAC.utils.monitoring import UTLOGGER +from CPAC.utils.outputs import Outputs + +ONE_OFFS: dict[str, list[str]] = { + r".*desc-preproc_bold": ["func_ingress"], + r".*-sm.*": [ + f"spatial_smoothing_{smooth_opt}" + for smooth_opt in latest_schema.schema["post_processing"]["spatial_smoothing"][ + "smoothing_method" + ][0].container + ], + r".*-zstd.*": [f"{fisher}zscore_standardize" for fisher in ["", "fisher_"]], +} +"""A few out-of-nodeblock generated resources. + +Easier to note these manually than to code up the AST rules.""" + +SKIPS: list[str] = [ + "CPAC.unet.__init__", + "CPAC.unet._torch", +] +"""No nodeblock functions in these modules that dynamically install `torch`.""" + + +def import_nodeblock_functions( + package_name: str, exclude: Optional[list[str]] = None +) -> list[NodeBlockFunction]: + """ + Import all functions with the @nodeblock decorator from all modules and submodules in a package. + + Parameters + ---------- + package_name + The name of the package to import from. + + exclude + A list of module names to exclude from the import. + """ + if exclude is None: + exclude = [] + functions: list[NodeBlockFunction] = [] + package = importlib.import_module(package_name) + package_path = package.__path__[0] # Path to the package directory + + for root, _, package_files in os.walk(package_path): + for file in package_files: + if file.endswith(".py") and file != "__init__.py": + # Get the module path + rel_path = os.path.relpath(os.path.join(root, file), package_path) + module_name = f"{package_name}.{rel_path[:-3].replace(os.sep, '.')}" + if module_name in exclude: + continue + + # Import the module + try: + with patch.dict( + "sys.modules", {exclusion: None for exclusion in exclude} + ): + module = importlib.import_module(module_name) + except (ImportError, TraitError, ValueError) as e: + UTLOGGER.debug(f"Failed to import {module_name}: {e}") + continue + # Extract nodeblock-decorated functions from the module + for _name, obj in inspect.getmembers( + module, predicate=lambda obj: isinstance(obj, NodeBlockFunction) + ): + functions.append(obj) + + return functions + + +@dataclass +class ResourceSourceList: + """A list of resource sources without duplicates.""" + + sources: list[str] = field(default_factory=list) + + def __add__(self, other: "str | list[str] | ResourceSourceList") -> list[str]: + """Add a list of sources to the list.""" + if isinstance(other, str): + if not other or other == "created_before_this_test": + # dummy node in a testing function, no need to include in inventory + return list(self) + other = [other] + new_set = {*self.sources, *other} + return sorted(new_set, key=str.casefold) + + def __contains__(self, item: str) -> bool: + """Check if a source is in the list.""" + return item in self.sources + + def __delitem__(self, key: int) -> None: + """Delete a source by index.""" + del self.sources[key] + + def __eq__(self, value: Any) -> bool: + """Check if the lists of sources are the same.""" + return set(self) == set(value) + + def __getitem__(self, item: int) -> str: + """Get a source by index.""" + return self.sources[item] + + def __hash__(self) -> int: + """Get the hash of the list of sources.""" + return hash(self.sources) + + def __iadd__( + self, other: "str | list[str] | ResourceSourceList" + ) -> "ResourceSourceList": + """Add a list of sources to the list.""" + self.sources = self + other + return self + + def __iter__(self): + """Iterate over the sources.""" + return iter(self.sources) + + def __len__(self) -> int: + """Get the number of sources.""" + return len(self.sources) + + def __repr__(self) -> str: + """Get the reproducable string representation of the sources.""" + return f"ResourceSourceList({(self.sources)})" + + def __reversed__(self) -> list[str]: + """Get the sources reversed.""" + return list(reversed(self.sources)) + + def __setitem__(self, key: int, value: str) -> None: + """Set a source by index.""" + self.sources[key] = value + + def __sorted__(self) -> list[str]: + """Get the sources sorted.""" + return sorted(self.sources, key=str.casefold) + + def __str__(self) -> str: + """Get the string representation of the sources.""" + return str(self.sources) + + +@dataclass +class ResourceIO: + """NodeBlockFunctions that use a resource for IO.""" + + name: str + """The name of the resource.""" + output_from: ResourceSourceList | list[str] = field( + default_factory=ResourceSourceList + ) + """The functions that output the resource.""" + output_to: ResourceSourceList | list[str] = field( + default_factory=ResourceSourceList + ) + """The subdirectory the resource is output to.""" + input_for: ResourceSourceList | list[str] = field( + default_factory=ResourceSourceList + ) + """The functions that use the resource as input.""" + + def __post_init__(self) -> None: + """Handle optionals.""" + if isinstance(self.output_from, list): + self.output_from = ResourceSourceList(self.output_from) + if isinstance(self.output_to, list): + self.output_to = ResourceSourceList(self.output_to) + if isinstance(self.input_for, list): + self.input_for = ResourceSourceList(self.input_for) + + def __str__(self) -> str: + """Return string representation for ResourceIO instance.""" + return f"{{{self.name}: {{'input_for': {self.input_for!s}, 'output_from': {self.output_from!s}}}}})" + + def as_dict(self) -> dict[str, list[str]]: + """Return the ResourceIO as a built-in dictionary type.""" + return { + k: v + for k, v in { + "input_for": [str(source) for source in self.input_for], + "output_from": [str(source) for source in self.output_from], + "output_to": [str(source) for source in self.output_to], + }.items() + if v + } + + +def cli_parser() -> Namespace: + """Parse command line argument.""" + parser = ArgumentParser( + description="Inventory resources for C-PAC NodeBlockFunctions.", + formatter_class=ArgumentDefaultsHelpFormatter, + ) + parser.add_argument( + "-o", + "--output", + nargs="?", + help="The output file to write the inventory to.", + type=Path, + default=Path("resource_inventory.yaml"), + ) + return parser.parse_args() + + +def _flatten_io(io: Iterable[Iterable]) -> list[str]: + """Given a list of strings or iterables thereof, flatten the list to all strings.""" + if all(isinstance(resource, str) for resource in io): + return cast(list[str], io) + while not all(isinstance(resource, str) for resource in io): + io = list( + chain.from_iterable( + [ + resource if not isinstance(resource, str) else [resource] + for resource in io + ] + ) + ) + return cast(list[str], io) + + +class MultipleContext(list): + """Subclass of list to store multilpe contexts.""" + + def __init__(self, /, *args, **kwargs) -> None: + """Initialize MultipleContext.""" + super().__init__(*args, **kwargs) + data = self._unique(self) + self.clear() + self.extend(data) + + def __hash__(self) -> int: + """Hash a MultipleContext instance.""" + return hash(str(self)) + + def __str__(self) -> str: + """Return a stringified MultipleContext instance.""" + if len(self) == 1: + return str(self[0]) + return super().__str__() + + def append(self, item: Any) -> None: + """Append if not already included.""" + if item not in self: + super().append(item) + + def extend(self, iterable: Iterable) -> None: + """Extend MultipleContext.""" + for item in iterable: + self.append(item) + + @staticmethod + def _unique(iterable: Iterable) -> list: + """Dedupe.""" + try: + seen = set() + return [x for x in iterable if not (x in seen or seen.add(x))] + except TypeError: + seen = set() + return [ + x + for x in (MultipleContext(item) for item in iterable) + if not (x in seen or seen.add(x)) + ] + + +class DirectlySetResources(ast.NodeVisitor): + """Class to track resources set directly, rather than through NodeBlocks.""" + + def __init__(self) -> None: + """Initialize the visitor.""" + super().__init__() + self._context: dict[str, Any] = {} + self.dynamic_resources: dict[str, ResourceSourceList] = { + resource: ResourceSourceList(sources) + for resource, sources in ONE_OFFS.items() + } + self._history: dict[str, list[Any]] = {} + self.resources: dict[str, ResourceSourceList] = {} + + def assign_resource(self, resource: str, value: str | MultipleContext) -> None: + """Assign a value to a resource.""" + if isinstance(resource, ast.AST): + resource = self.parse_ast(resource) + resource = str(resource) + if isinstance(value, MultipleContext): + for subvalue in value: + self.assign_resource(resource, subvalue) + return + target = ( + self.dynamic_resources + if r".*" in value or r".*" in resource + else self.resources + ) + if resource not in target: + target[resource] = ResourceSourceList() + target[resource] += value + + @property + def context(self) -> dict[str, Any]: + """Return the context.""" + return self._context + + @context.setter + def context(self, value: tuple[Iterable, Any]) -> None: + """Set the context.""" + key, _value = value + if not isinstance(key, str): + for subkey in key: + self.context = subkey, _value + else: + self._context[key] = _value + if key not in self._history: + self._history[key] = [".*"] + self._history[key].append(_value) + + def lookup_context( + self, variable: str, return_type: Optional[type] = None + ) -> str | MultipleContext: + """Plug in variable.""" + if variable in self.context: + if self.context[variable] == variable or ( + return_type and not isinstance(self.context[variable], return_type) + ): + history = list(self._history[variable]) + while history and history[-1] == variable: + history.pop() + if history: + context = history[-1] + while ( + return_type + and len(history) + and not isinstance(context, return_type) + ): + context = history.pop() + if return_type and not isinstance(context, return_type): + return ".*" + return context + return self.context[variable] + return ".*" + + @staticmethod + def handle_multiple_contexts(contexts: list[str | list[str]]) -> list[str]: + """Parse multiple contexts.""" + if isinstance(contexts, list): + return MultipleContext( + [ + "".join(list(ctx)) + for ctx in product( + *[ + context if isinstance(context, list) else [context] + for context in contexts + ] + ) + ] + ) + return contexts + + def parse_ast(self, node: Any) -> Any: + """Parse AST.""" + if not isinstance(node, ast.AST): + if isinstance(node, str) or not isinstance(node, Iterable): + return str(node) + if isinstance(node, ast.Dict): + return { + self.parse_ast(key): self.parse_ast(value) + for key, value in dict(zip(node.keys, node.values)).items() + } + if isinstance(node, (MultipleContext, list, set, tuple)): + return type(node)(self.parse_ast(subnode) for subnode in node) + if isinstance(node, ast.FormattedValue): + if hasattr(node, "value") and hasattr(node.value, "id"): + return self.lookup_context(getattr(node.value, "id")) + if isinstance(node, ast.JoinedStr): + node_values = [self.parse_ast(value) for value in node.values] + if any(isinstance(value, MultipleContext) for value in node_values): + return self.handle_multiple_contexts(node_values) + return "".join(str(item) for item in node_values) + if isinstance(node, ast.Dict): + return { + self.parse_ast(key) + if isinstance(self.parse_ast(key), Hashable) + else ".*": self.parse_ast(value) + for key, value in dict(zip(node.keys, node.values)).items() + } + if not isinstance(node, ast.Call): + for attr in ["values", "elts", "args"]: + if hasattr(node, attr): + iterable = getattr(node, attr) + if isinstance(iterable, Iterable): + return [ + self.parse_ast(subnode) for subnode in getattr(node, attr) + ] + return self.parse_ast(iterable) + for attr in ["value", "id", "arg"]: + if hasattr(node, attr): + return self.parse_ast(getattr(node, attr)) + elif ( + hasattr(node, "func") + and getattr(node.func, "attr", None) in ["items", "keys", "values"] + and getattr(getattr(node.func, "value", None), "id", None) in self.context + ): + context = self.lookup_context(node.func.value.id, return_type=dict) + if isinstance(context, dict): + return MultipleContext(getattr(context, node.func.attr)()) + return r".*" # wildcard for regex matching + + def visit_Assign(self, node: ast.Assign) -> None: + """Visit an assignment.""" + value = self.parse_ast(node.value) + if value == "row" and getattr(node.value, "attr", None): + # hack for template dataframe + value = MultipleContext(getattr(template_dataframe(), node.value.attr)) + for target in node.targets: + resource = self.parse_ast(target) + self.context = resource, value + self.generic_visit(node) + + def visit_Call(self, node: ast.Call) -> None: + """Visit a function call.""" + if isinstance(node.func, ast.Attribute) and node.func.attr == "set_data": + value = self.parse_ast(node.args[5]) + if isinstance(node.args[5], ast.Name): + if isinstance(value, str): + value = self.lookup_context(value) + if hasattr(node.args[0], "value"): + resource: str = getattr(node.args[0], "value") + elif hasattr(node.args[0], "id"): + resource = self.lookup_context(getattr(node.args[0], "id")) + if isinstance(resource, MultipleContext): + if len(resource) == len(value): + for k, v in zip(resource, value): + self.assign_resource(k, v) + else: + for resource_context in resource: + self.assign_resource(resource_context, value) + self.generic_visit(node) + return + elif isinstance(node.args[0], ast.JoinedStr): + resource = self.parse_ast(node.args[0]) + else: + self.generic_visit(node) + return + self.assign_resource(resource, value) + self.generic_visit(node) + + def visit_For(self, node: ast.For) -> None: + """Vist for loop.""" + target = self.parse_ast(node.target) + if ( + hasattr(node.iter, "func") + and hasattr(node.iter.func, "value") + and hasattr(node.iter.func.value, "id") + ): + context = self.parse_ast(node.iter) + if not context: + context = r".*" + if isinstance(target, list): + target_len = len(target) + if isinstance(context, dict): + self.context = target[0], MultipleContext(context.keys()) + if isinstance(context, list) and all( + (isinstance(item, tuple) and len(item) == target_len) + for item in context + ): + for index, item in enumerate(target): + self.context = ( + item, + MultipleContext( + subcontext[index] for subcontext in context + ), + ) + elif hasattr(node.iter, "value") and ( + getattr(node.iter.value, "id", None) == "self" + or getattr(node.iter, "attr", False) + ): + self.context = target, ".*" + else: + self.context = target, self.parse_ast(node.iter) + self.generic_visit(node) + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + """Visit a function definition.""" + if node.name == "set_data": + # skip the method definition + return + for arg in self.parse_ast(node): + self.context = arg, ".*" + self.generic_visit(node) + + +def find_directly_set_resources( + package_name: str, +) -> tuple[dict[str, ResourceSourceList], dict[str, ResourceSourceList]]: + """Find all resources set explicitly via :pyy:method:`~CPAC.pipeline.engine.ResourcePool.set_data`. + + Parameters + ---------- + package_name + The name of the package to search for resources. + + Returns + ------- + dict + A dictionary containing the name of the resource and the name of the functions that set it. + + dict + A dictionary containing regex strings for special cases + """ + resources: dict[str, ResourceSourceList] = {} + dynamic_resources: dict[str, ResourceSourceList] = {} + for dirpath, _, filenames in os.walk(str(files(package_name))): + for filename in filenames: + if filename.endswith(".py"): + filepath = os.path.join(dirpath, filename) + with open(filepath, "r", encoding="utf-8") as file: + tree = ast.parse(file.read(), filename=filepath) + directly_set = DirectlySetResources() + directly_set.visit(tree) + for resource in directly_set.resources: + if resource not in resources: + resources[resource] = ResourceSourceList() + resources[resource] += directly_set.resources[resource] + for resource in directly_set.dynamic_resources: + if resource not in dynamic_resources: + dynamic_resources[resource] = ResourceSourceList() + dynamic_resources[resource] += directly_set.dynamic_resources[ + resource + ] + return resources, dynamic_resources + + +def resource_inventory(package: str = "CPAC") -> dict[str, ResourceIO]: + """Gather all inputs and outputs for a list of NodeBlockFunctions.""" + resources: dict[str, ResourceIO] = {} + # Node block function inputs and outputs + for nbf in import_nodeblock_functions( + package, + exclude=SKIPS, + ): + nbf_name = f"{nbf.name} ({nbf.__module__}.{nbf.__qualname__})" + if hasattr(nbf, "inputs"): + for nbf_input in _flatten_io(cast(list[Iterable], nbf.inputs)): + if nbf_input: + if nbf_input not in resources: + resources[nbf_input] = ResourceIO( + nbf_input, input_for=[nbf_name] + ) + else: + resources[nbf_input].input_for += nbf_name + if hasattr(nbf, "outputs"): + for nbf_output in _flatten_io(cast(list[Iterable], nbf.outputs)): + if nbf_output: + if nbf_output not in resources: + resources[nbf_output] = ResourceIO( + nbf_output, output_from=[nbf_name] + ) + else: + resources[nbf_output].output_from += nbf_name + # Template resources set from pipeline config + templates_from_config_df = template_dataframe() + for _, row in templates_from_config_df.iterrows(): + output_from = f"pipeline configuration: {row.Pipeline_Config_Entry}" + if row.Key not in resources: + resources[row.Key] = ResourceIO(row.Key, output_from=[output_from]) + else: + resources[row.Key].output_from += output_from + # Hard-coded resources + direct, dynamic = find_directly_set_resources(package) + for resource, functions in direct.items(): + if resource not in resources: + resources[resource] = ResourceIO(resource, output_from=functions) + else: + resources[resource].output_from += functions + # Outputs + for _, row in Outputs.reference.iterrows(): + if row.Resource not in resources: + resources[row.Resource] = ResourceIO( + row.Resource, output_to=[row["Sub-Directory"]] + ) + else: + resources[row.Resource].output_to += row["Sub-Directory"] + # Special cases + for dynamic_key, dynamic_value in dynamic.items(): + if dynamic_key != r".*": + dynamic_resource = re.compile(dynamic_key) + for resource in resources.keys(): + if dynamic_resource.search(resource): + resources[resource].output_from += dynamic_value + if "interface" in resources: + # this is a loop in setting up nodeblocks + # https://github.com/FCP-INDI/C-PAC/blob/61ad414447023daf0e401a81c92267b09c64ed94/CPAC/pipeline/engine.py#L1453-L1464 + # it's already handled in the NodeBlock resources + del resources["interface"] + return dict(sorted(resources.items(), key=lambda item: item[0].casefold())) + + +def dump_inventory_to_yaml(inventory: dict[str, ResourceIO]) -> str: + """Dump NodeBlock Interfaces to a YAML string.""" + return yaml.dump( + {key: value.as_dict() for key, value in inventory.items()}, sort_keys=False + ) + + +def where_to_find(resources: list[str] | str) -> str: + """Return a multiline string describing where each listed resource is output from.""" + if isinstance(resources, str): + resources = [resources] + resources = _flatten_io(resources) + inventory = resource_inventory("CPAC") + output = "" + for resource in resources: + output += f"'{resource}' can be output from:\n" + if resource in inventory: + for source in inventory[resource].output_from: + output += f" {source}\n" + else: + output += " !! Nowhere !!\n" + output += "\n" + return output.rstrip() + + +def main() -> None: + """Save the NodeBlock inventory to a file.""" + args = cli_parser() + with args.output.open("w") as file: + file.write(dump_inventory_to_yaml(resource_inventory("CPAC"))) + + +if __name__ == "__main__": + main() diff --git a/CPAC/pipeline/schema.py b/CPAC/pipeline/schema.py index 915cb47045..81c542e8e5 100644 --- a/CPAC/pipeline/schema.py +++ b/CPAC/pipeline/schema.py @@ -1,4 +1,4 @@ -# Copyright (C) 2022-2024 C-PAC Developers +# Copyright (C) 2022-2025 C-PAC Developers # This file is part of C-PAC. @@ -18,9 +18,18 @@ """Validation schema for C-PAC pipeline configurations.""" # pylint: disable=too-many-lines +from dataclasses import dataclass from itertools import chain, permutations import re from subprocess import CalledProcessError +from typing import ( + Any as AnyType, + Literal, + Optional as OptionalType, + TypeAlias, + TypedDict, +) +import warnings import numpy as np from pathvalidate import sanitize_filename @@ -46,9 +55,11 @@ Schema, Title, ) +from voluptuous.schema_builder import Schemable, UNDEFINED from CPAC.utils.datatypes import ItemFromList, ListFromItem from CPAC.utils.docs import DOCS_URL_PREFIX +from CPAC.utils.monitoring import UTLOGGER from CPAC.utils.utils import YAML_BOOLS # 1 or more digits, optional decimal, 'e', optional '-', 1 or more digits @@ -61,6 +72,71 @@ RESOLUTION_REGEX = r"^[0-9]+(\.[0-9]*){0,1}[a-z]*(x[0-9]+(\.[0-9]*){0,1}[a-z]*)*$" Number = Any(float, int, All(str, Match(SCIENTIFIC_NOTATION_STR_REGEX))) +Organism: TypeAlias = Literal[ + "human", + "non-human primate", + "rodent", +] +ORGANISMS: list[Organism] = ["human", "non-human primate", "rodent"] + + +def deprecated_option(option: Schemable, version: str, message: str) -> None: + """Mark an option as deprecated. + + Parameters + ---------- + option + The deprecated option. + version + The version in which the option was deprecated. + message + A message explaining the deprecation. + """ + UTLOGGER.warning( + f"Option '{option}' is deprecated as of version {version}: {message}" + ) + warnings.warn( + f"Option '{option}' is deprecated as of version {version}: {message}", + DeprecationWarning, + stacklevel=2, + ) + + +@dataclass +class DeprecatedOption: + """A version and message for a deprecated option.""" + + version: str + message: str + + +class Deprecated(Optional): + """Mark an option as deprecated. + + This class is used to mark options that are deprecated in the schema. + It inherits from `Optional` to allow the option to be omitted. + """ + + def __init__( + self, + schema: Schemable, + version: str, + msg: str = "This option is deprecated and will be removed in a future release.", + default: AnyType = UNDEFINED, + description: AnyType | None = None, + ) -> None: + """Initialize the Deprecated option.""" + super().__init__(schema, msg, default, description) + setattr(self, "deprecated", DeprecatedOption(version, msg)) + + def __call__(self, v: AnyType) -> AnyType: + """Call the Deprecated option.""" + if v is not None: + info = getattr(self, "deprecated", None) + if info: + deprecated_option(self._schema, info.version, info.message) + return super().__call__(v) + return v def str_to_bool1_1(x): # pylint: disable=invalid-name @@ -102,8 +178,10 @@ def str_to_bool1_1(x): # pylint: disable=invalid-name bool1_1 = All(str_to_bool1_1, bool) forkable = All(Coerce(ListFromItem), [bool1_1], Length(max=2)) +MotionCorrection: TypeAlias = Literal["3dvolreg", "mcflirt"] valid_options = { "acpc": {"target": ["brain", "whole-head"]}, + "deoblique": ["warp", "refit"], "brain_extraction": { "using": [ "3dSkullStrip", @@ -266,6 +344,21 @@ def str_to_bool1_1(x): # pylint: disable=invalid-name dict, # TODO: specify other valid ANTs parameters ) ] + + +class MotionEstimateFilter(TypedDict): + """Type for motion estimate filter.""" + + filter_type: Literal["notch", "lowpass"] + filter_order: int + breathing_rate_min: OptionalType[float] + breathing_rate_max: OptionalType[float] + center_frequency: OptionalType[float] + filter_bandwidth: OptionalType[float] + lowpass_cutoff: OptionalType[float] + Name: OptionalType[str] + + motion_estimate_filter = Any( { # notch filter with breathing_rate_* set Required("filter_type"): "notch", @@ -423,6 +516,10 @@ def sanitize(filename): "skip env check": Maybe(bool), # flag for skipping an environment check "pipeline_setup": { "pipeline_name": All(str, Length(min=1), sanitize), + "organism": In(ORGANISMS), + "desired_orientation": In( + {"RPI", "LPI", "RAI", "LAI", "RAS", "LAS", "RPS", "LPS"} + ), "output_directory": { "path": str, "source_outputs_dir": Maybe(str), @@ -507,6 +604,7 @@ def sanitize(filename): "Debugging": { "verbose": bool1_1, }, + "freesurfer_dir": str, "outdir_ingress": { "run": bool1_1, "Template": Maybe(str), @@ -515,6 +613,7 @@ def sanitize(filename): "anatomical_preproc": { "run": bool1_1, "run_t2": bool1_1, + "deoblique": [In(valid_options["deoblique"])], "non_local_means_filtering": { "run": forkable, "noise_model": Maybe(str), @@ -635,6 +734,9 @@ def sanitize(filename): }, "FreeSurfer-BET": {"T1w_brain_template_mask_ccs": Maybe(str)}, }, + "restore_t1w_intensity": { + "run": bool1_1, + }, }, "segmentation": { "run": bool1_1, @@ -685,6 +787,7 @@ def sanitize(filename): }, }, "registration_workflows": { + "sink_native_transforms": bool1_1, "anatomical_registration": { "run": bool1_1, "resolution_for_anat": All(str, Match(RESOLUTION_REGEX)), @@ -709,8 +812,6 @@ def sanitize(filename): "interpolation": In({"trilinear", "sinc", "spline"}), "identity_matrix": Maybe(str), "ref_mask": Maybe(str), - "ref_mask_res-2": Maybe(str), - "T1w_template_res-2": Maybe(str), }, }, "overwrite_transform": { @@ -721,15 +822,12 @@ def sanitize(filename): "functional_registration": { "coregistration": { "run": bool1_1, - "reference": In({"brain", "restore-brain"}), "interpolation": In({"trilinear", "sinc", "spline"}), "using": str, - "input": str, "cost": str, "dof": int, "arguments": Maybe(str), "func_input_prep": { - "reg_with_skull": bool1_1, "input": [ In( { @@ -740,14 +838,17 @@ def sanitize(filename): ) ], "Mean Functional": {"n4_correct_func": bool1_1}, - "Selected Functional Volume": {"func_reg_input_volume": int}, + "Selected Functional Volume": { + "func_reg_input_volume": int, + }, + "mask_sbref": bool1_1, }, "boundary_based_registration": { "run": forkable, + "reference": In({"whole-head", "brain"}), "bbr_schedule": str, "bbr_wm_map": In({"probability_map", "partial_volume_map"}), "bbr_wm_mask_args": str, - "reference": In({"whole-head", "brain"}), }, }, "EPI_registration": { @@ -813,6 +914,8 @@ def sanitize(filename): "surface_analysis": { "abcd_prefreesurfer_prep": { "run": bool1_1, + "ref_mask_res-2": Maybe(str), + "T1w_template_res-2": Maybe(str), }, "freesurfer": { "run_reconall": bool1_1, @@ -870,6 +973,7 @@ def sanitize(filename): }, "update_header": { "run": bool1_1, + "deoblique": [In(valid_options["deoblique"])], }, "scaling": {"run": bool1_1, "scaling_factor": Number}, "despiking": {"run": forkable, "space": In({"native", "template"})}, @@ -880,7 +984,11 @@ def sanitize(filename): }, "motion_estimates_and_correction": { "run": bool1_1, - "motion_estimates": { + Deprecated( + "motion_estimates", + version="v1.8.8", + msg="The option to choose whether to calculate motion estimates before or after slice-timing correction was removed in v1.8.8 and will have no effect. This configuration option will be removed in a future release.", + ): { "calculate_motion_first": bool1_1, "calculate_motion_after": bool1_1, }, @@ -953,7 +1061,6 @@ def sanitize(filename): "FSL_AFNI", "Anatomical_Refined", "Anatomical_Based", - "Anatomical_Resampled", "CCS_Anatomical_Refined", ] ) @@ -1004,6 +1111,10 @@ def sanitize(filename): }, "apply_func_mask_in_native_space": bool1_1, }, + "template_space_func_masking": { + "run": bool1_1, + "using": [In({"Anatomical_Resampled"})], + }, "generate_func_mean": { "run": bool1_1, }, @@ -1033,7 +1144,14 @@ def sanitize(filename): { "Name": Required(str), "Censor": { - "method": str, + "method": In( + [ + "Kill", + "Zero", + "Interpolate", + "SpikeRegression", + ] + ), "thresholds": [ { "type": str, @@ -1253,20 +1371,12 @@ def sanitize(filename): ) -def schema(config_dict): +def schema(config_dict: dict) -> dict: """Validate a participant-analysis pipeline configuration. Validate against the latest validation schema by first applying backwards- compatibility patches, then applying Voluptuous validation, then handling complex configuration interaction checks before returning validated config_dict. - - Parameters - ---------- - config_dict : dict - - Returns - ------- - dict """ from CPAC.utils.utils import _changes_1_8_0_to_1_8_1 @@ -1385,6 +1495,25 @@ def schema(config_dict): " Try turning one option off.\n " ) raise ExclusiveInvalid(msg) + + overwrite = partially_validated["registration_workflows"][ + "anatomical_registration" + ]["overwrite_transform"] + + if ( + overwrite["run"] + and "ANTS" + not in partially_validated["registration_workflows"][ + "anatomical_registration" + ]["registration"]["using"] + ): + msg = ( + "[!] Overwrite transform method is the same as the anatomical " + "registration method! No need to overwrite transform with the same " + "registration method. Please turn it off or use a different " + "registration method." + ) + raise ExclusiveInvalid(msg) except KeyError: pass try: @@ -1418,4 +1547,4 @@ def schema(config_dict): return partially_validated -schema.schema = latest_schema.schema +schema.schema = latest_schema.schema # type: ignore[reportFunctionMemberAccess] diff --git a/CPAC/pipeline/test/test_connect_pipeline.py b/CPAC/pipeline/test/test_connect_pipeline.py new file mode 100644 index 0000000000..6677fe7c2f --- /dev/null +++ b/CPAC/pipeline/test/test_connect_pipeline.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +# Copyright (C) 2025 C-PAC Developers + +# This file is part of C-PAC. + +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. + +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . +"""Test pipeline connections.""" + +from logging import INFO +import multiprocessing.resource_tracker +from pathlib import Path +from typing import Callable + +import pytest +import yaml + +from CPAC.pipeline.cpac_runner import run +from CPAC.utils.configuration.configuration import Preconfiguration +from CPAC.utils.configuration.yaml_template import create_yaml_from_template +from CPAC.utils.monitoring import log_nodes_cb + +_unregister = multiprocessing.resource_tracker.unregister + + +def safe_unregister(name, rtype) -> None: + """Suppress unregister warnings.""" + try: + _unregister(name, rtype) + except KeyError: + pass + + +multiprocessing.resource_tracker.unregister = safe_unregister + + +@pytest.mark.parametrize("preconfig", ["abcd-options"]) +def test_config( + caplog: pytest.LogCaptureFixture, preconfig: str, tmp_path: Path +) -> None: + """Run 'test_config' analysis level.""" + caplog.set_level(INFO) + data_config_file = tmp_path / "data_config.yaml" + with data_config_file.open("w") as _f: + yaml.dump( + [ + { + "anat": "s3://fcp-indi/data/Projects/ADHD200/RawDataBIDS/KKI/sub-1019436/ses-1/anat/sub-1019436_ses-1_run-1_T1w.nii.gz", + "func": { + "rest_acq-1_run-1": { + "scan": "s3://fcp-indi/data/Projects/ADHD200/RawDataBIDS/KKI/sub-1019436/ses-1/func/sub-1019436_ses-1_task-rest_acq-1_run-1_bold.nii.gz", + "scan_parameters": "s3://fcp-indi/data/Projects/ADHD200/RawDataBIDS/KKI/task-rest_acq-1_bold.json", + } + }, + "site": "KKI", + "subject_id": "1019436", + "unique_id": "1", + } + ], + _f, + ) + + # output in tmp_path/outputs + pipeline = Preconfiguration(preconfig) + output_dir = tmp_path / "outputs" + output_dir.mkdir(parents=True, exist_ok=True) + pipeline["pipeline_setup", "log_directory", "path"] = str(output_dir / "log") + pipeline["pipeline_setup", "output_directory", "path"] = str(output_dir / "out") + pipeline["pipeline_setup", "working_directory", "path"] = str( + output_dir / "working" + ) + pipeline_file = tmp_path / "pipe_config.yaml" + with pipeline_file.open("w") as _f: + _f.write(create_yaml_from_template(pipeline, preconfig, preconfig, True)) + + plugin = "MultiProc" + plugin_args: dict[str, int | bool | Callable] = { + "n_procs": 2, + "memory_gb": 10, + "raise_insufficient": True, + "status_callback": log_nodes_cb, + } + tracking = False + exitcode = run( + str(data_config_file), + str(pipeline_file), + plugin=plugin, + plugin_args=plugin_args, + tracking=tracking, + test_config=True, + ) + if exitcode != 0: + records = list(caplog.records) + msg: str + msg = str(records[-1]) + if hasattr(records[-1], "exc_info"): + exc_info = records[-1].exc_info + if ( + exc_info + and exc_info[0] + and exc_info[1] + and hasattr(exc_info[1], "args") + ): + msg = exc_info[1].args[0] + raise exc_info[0](exc_info[1]) + raise AssertionError(msg) diff --git a/CPAC/pipeline/test/test_cpac_group_runner.py b/CPAC/pipeline/test/test_cpac_group_runner.py index d8a218ca19..6c20341ede 100644 --- a/CPAC/pipeline/test/test_cpac_group_runner.py +++ b/CPAC/pipeline/test/test_cpac_group_runner.py @@ -14,12 +14,11 @@ # You should have received a copy of the GNU Lesser General Public # License along with C-PAC. If not, see . -from logging import basicConfig, INFO + from CPAC.utils.monitoring.custom_logging import getLogger logger = getLogger("CPAC.pipeline.test") -basicConfig(format="%(message)s", level=INFO) def run_gather_outputs_func(pipeline_out_dir): diff --git a/CPAC/pipeline/test/test_cpac_runner.py b/CPAC/pipeline/test/test_cpac_runner.py index 7ee91f5125..575cfc970a 100644 --- a/CPAC/pipeline/test/test_cpac_runner.py +++ b/CPAC/pipeline/test/test_cpac_runner.py @@ -1,17 +1,45 @@ +# Copyright (C) 2021-2025 C-PAC Developers + +# This file is part of C-PAC. + +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. + +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . +"""Run C-PAC in a container.""" + import os +from pathlib import Path -import pkg_resources as p import pytest from CPAC.pipeline.cpac_pipeline import load_cpac_pipe_config from CPAC.pipeline.cpac_runner import run_T1w_longitudinal +from CPAC.pipeline.utils import get_shell +from CPAC.resources.configs import CONFIGS_PATH from CPAC.utils.bids_utils import create_cpac_data_config +def test_shell() -> None: + """Test that ``get_shell`` returns a path to an executable BASH.""" + shell: str = get_shell() + assert shell.lower().endswith("bash"), "Default shell isn't BASH?" + assert Path(shell).exists(), "No default shell found." + assert os.access(shell, os.X_OK), "Default shell not executable." + + @pytest.mark.skip(reason="not a pytest test") def test_run_T1w_longitudinal(bids_dir, cfg, test_dir, part_id): sub_data_list = create_cpac_data_config( - bids_dir, participant_label=part_id, skip_bids_validator=True + bids_dir, participant_labels=[part_id], skip_bids_validator=True ) cfg = load_cpac_pipe_config(cfg) @@ -21,12 +49,9 @@ def test_run_T1w_longitudinal(bids_dir, cfg, test_dir, part_id): run_T1w_longitudinal(sub_data_list, cfg) -cfg = p.resource_filename( - "CPAC", os.path.join("resources", "configs", "pipeline_config_default.yml") -) -bids_dir = "/Users/steven.giavasis/data/neurodata_hnu" -test_dir = "/test_dir" -part_id = "0025427" - if __name__ == "__main__": + bids_dir = "/Users/steven.giavasis/data/neurodata_hnu" + test_dir = "/test_dir" + part_id = "0025427" + cfg = str(CONFIGS_PATH / "pipeline_config_default.yml") test_run_T1w_longitudinal(bids_dir, cfg, test_dir, part_id) diff --git a/CPAC/pipeline/test/test_engine.py b/CPAC/pipeline/test/test_engine.py index c228fc3640..1489362e9a 100644 --- a/CPAC/pipeline/test/test_engine.py +++ b/CPAC/pipeline/test/test_engine.py @@ -1,5 +1,27 @@ +# Copyright (C) 2021-2025 C-PAC Developers + +# This file is part of C-PAC. + +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. + +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . +"""Unit tests for the C-PAC pipeline engine.""" + +from argparse import Namespace import os +from pathlib import Path +from typing import cast, Protocol +from _pytest.logging import LogCaptureFixture import pytest from CPAC.pipeline.cpac_pipeline import ( @@ -17,6 +39,7 @@ ResourcePool, ) from CPAC.utils.bids_utils import create_cpac_data_config +from CPAC.utils.test_mocks import file_node @pytest.mark.skip(reason="not a pytest test") @@ -90,7 +113,7 @@ def test_ingress_pipeconfig_data(pipe_config, bids_dir, test_dir): rpool = ResourcePool(name=unique_id, cfg=cfg) - rpool = ingress_pipeconfig_paths(cfg, rpool, sub_data_dct, unique_id) + wf, rpool = ingress_pipeconfig_paths(wf, cfg, rpool, sub_data_dct, unique_id) rpool.gather_pipes(wf, cfg, all=True) @@ -138,17 +161,150 @@ def test_build_workflow(pipe_config, bids_dir, test_dir): wf.run() +def test_missing_resource( + bids_examples: Path, caplog: LogCaptureFixture, tmp_path: Path +) -> None: + """Test the error message thrown when a resource is missing.""" + from datetime import datetime + + import yaml + + from CPAC.pipeline.cpac_runner import run + from CPAC.utils.bids_utils import sub_list_filter_by_labels + from CPAC.utils.configuration import Preconfiguration, set_subject + from CPAC.utils.configuration.yaml_template import create_yaml_from_template + + st = datetime.now().strftime("%Y-%m-%dT%H-%M-%SZ") + namespace = Namespace( + bids_dir=str(bids_examples / "ds113b"), + output_dir=str(tmp_path / "output"), + analysis_level="test_config", + participant_label="sub-01", + ) + c = Preconfiguration("anat-only") + c["pipeline_setup", "output_directory", "path"] = namespace.output_dir + c["pipeline_setup", "log_directory", "path"] = str(tmp_path / "logs") + c["pipeline_setup", "working_directory", "path"] = str(tmp_path / "work") + c["pipeline_setup", "system_config", "maximum_memory_per_participant"] = 1.0 + c["pipeline_setup", "system_config", "max_cores_per_participant"] = 1 + c["pipeline_setup", "system_config", "num_participants_at_once"] = 1 + c["pipeline_setup", "system_config", "num_ants_threads"] = 1 + c["pipeline_setup", "working_directory", "remove_working_dir"] = True + sub_list = create_cpac_data_config( + namespace.bids_dir, + namespace.participant_label, + None, + True, + only_one_anat=False, + ) + sub_list = sub_list_filter_by_labels(list(sub_list), {"T1w": None, "bold": None}) + for i, sub in enumerate(sub_list): + if isinstance(sub.get("anat"), dict): + for anat_key in sub["anat"]: + if isinstance(sub["anat"][anat_key], list) and len( + sub["anat"][anat_key] + ): + sub_list[i]["anat"][anat_key] = sub["anat"][anat_key][0] + if isinstance(sub.get("anat"), list) and len(sub["anat"]): + sub_list[i]["anat"] = sub["anat"][0] + data_config_file = f"cpac_data_config_{st}.yml" + sublogdirs = [set_subject(sub, c)[2] for sub in sub_list] + # write out the data configuration file + data_config_file = os.path.join(sublogdirs[0], data_config_file) + with open(data_config_file, "w", encoding="utf-8") as _f: + noalias_dumper = yaml.dumper.SafeDumper + noalias_dumper.ignore_aliases = lambda self, data: True + yaml.dump(sub_list, _f, default_flow_style=False, Dumper=noalias_dumper) + + # update and write out pipeline config file + pipeline_config_file = os.path.join(sublogdirs[0], f"cpac_pipeline_config_{st}.yml") + with open(pipeline_config_file, "w", encoding="utf-8") as _f: + _f.write(create_yaml_from_template(c)) + minimized_config = f"{pipeline_config_file[:-4]}_min.yml" + with open(minimized_config, "w", encoding="utf-8") as _f: + _f.write(create_yaml_from_template(c, import_from="blank")) + for config_file in (data_config_file, pipeline_config_file, minimized_config): + os.chmod(config_file, 0o444) # Make config files readonly + + if len(sublogdirs) > 1: + # If more than one run is included in the given data config + # file, an identical copy of the data and pipeline config + # will be included in the log directory for each run + for sublogdir in sublogdirs[1:]: + for config_file in ( + data_config_file, + pipeline_config_file, + minimized_config, + ): + try: + os.link(config_file, config_file.replace(sublogdirs[0], sublogdir)) + except FileExistsError: + pass + + run( + data_config_file, + pipeline_config_file, + plugin="Linear", + plugin_args={ + "n_procs": int( + cast( + int | str, + c["pipeline_setup", "system_config", "max_cores_per_participant"], + ) + ), + "memory_gb": int( + cast( + int | str, + c[ + "pipeline_setup", + "system_config", + "maximum_memory_per_participant", + ], + ) + ), + "raise_insufficient": c[ + "pipeline_setup", "system_config", "raise_insufficient" + ], + }, + tracking=False, + test_config=namespace.analysis_level == "test_config", + ) + + assert "can be output from" in caplog.text + + +class ResourceDownload(Protocol): + """Protocol for a callable that downloads a resource file.""" + + def __call__(self, file: str, destination: Path | str) -> Path: + """Return the path to the downloaded resource file.""" + ... + + +def _download( + self: ResourcePool, + resource: str, + source: ResourceDownload, + file: str, + destination: Path, + index: int = -1, +) -> None: + """Download a file from OSF into a ResourcePool.""" + node = file_node(source(file, destination), index, f"osf_{resource}") + self.set_data(resource, node[0], node[1], {}, -1, source.__module__) + + # bids_dir = "/Users/steven.giavasis/data/HBN-SI_dataset/rawdata" # test_dir = "/test_dir" # cfg = "/Users/hecheng.jin/GitHub/DevBranch/CPAC/resources/configs/pipeline_config_monkey-ABCD.yml" -cfg = "/Users/hecheng.jin/GitHub/pipeline_config_monkey-ABCDlocal.yml" -bids_dir = "/Users/hecheng.jin/Monkey/monkey_data_oxford/site-ucdavis" -test_dir = "/Users/hecheng.jin/GitHub/Test/T2preproc" # test_ingress_func_raw_data(cfg, bids_dir, test_dir) # test_ingress_anat_raw_data(cfg, bids_dir, test_dir) # test_ingress_pipeconfig_data(cfg, bids_dir, test_dir) # test_build_anat_preproc_stack(cfg, bids_dir, test_dir) if __name__ == "__main__": + cfg = "/Users/hecheng.jin/GitHub/pipeline_config_monkey-ABCDlocal.yml" + bids_dir = "/Users/hecheng.jin/Monkey/monkey_data_oxford/site-ucdavis" + test_dir = "/Users/hecheng.jin/GitHub/Test/T2preproc" test_build_workflow(cfg, bids_dir, test_dir) diff --git a/CPAC/pipeline/test/test_schema_validation.py b/CPAC/pipeline/test/test_schema_validation.py index 36a75a1a00..2d299eaa65 100644 --- a/CPAC/pipeline/test/test_schema_validation.py +++ b/CPAC/pipeline/test/test_schema_validation.py @@ -1,6 +1,7 @@ """Tests for schema.py.""" from itertools import combinations +import warnings import pytest from voluptuous.error import ExclusiveInvalid, Invalid @@ -12,7 +13,9 @@ "run_value", [True, False, [True], [False], [True, False], [False, True]] ) def test_motion_estimates_and_correction(run_value): - """Test that any truthy forkable option for 'run' throws the custom + """Test for human-readable exception for invalid motion_estimate_filter. + + Test that any truthy forkable option for 'run' throws the custom human-readable exception for an invalid motion_estimate_filter. """ # pylint: disable=invalid-name @@ -113,3 +116,61 @@ def test_pipeline_name(): """Test that pipeline_name sucessfully sanitizes.""" c = Configuration({"pipeline_setup": {"pipeline_name": ":va:lid name"}}) assert c["pipeline_setup", "pipeline_name"] == "valid_name" + + +@pytest.mark.parametrize( + "registration_using", + [ + list(combo) + for _ in [ + list(combinations(["ANTS", "FSL", "FSL-linear"], i)) for i in range(1, 4) + ] + for combo in _ + ], +) +def test_overwrite_transform(registration_using): + """Test that if overwrite transform method is already a registration method.""" + # pylint: disable=invalid-name + + d = { + "registration_workflows": { + "anatomical_registration": { + "registration": {"using": registration_using}, + "overwrite_transform": {"run": "On", "using": "FSL"}, + } + } + } + if "ANTS" in registration_using: + Configuration(d) # validates without exception + else: + with pytest.raises(ExclusiveInvalid) as e: + Configuration(d) + assert "Overwrite transform method is the same" in str(e.value) + + +@pytest.mark.parametrize( + "configuration", + [ + {}, + { + "functional_preproc": { + "motion_estimates_and_correction": { + "motion_estimates": { + "calculate_motion_first": False, + "calculate_motion_after": False, + } + } + } + }, + ], +) +def test_deprecation(configuration: dict) -> None: + """Test that deprecated options warn and non-deprecated options do not.""" + if configuration: + with pytest.warns(DeprecationWarning) as record: + Configuration(configuration) + assert any("motion_estimates" in str(w.message) for w in record) + else: + with warnings.catch_warnings(): + warnings.simplefilter("error") + Configuration(configuration) diff --git a/CPAC/pipeline/utils.py b/CPAC/pipeline/utils.py index 39acb6429f..834d0b4e1a 100644 --- a/CPAC/pipeline/utils.py +++ b/CPAC/pipeline/utils.py @@ -1,4 +1,4 @@ -# Copyright (C) 2021-2024 C-PAC Developers +# Copyright (C) 2021-2025 C-PAC Developers # This file is part of C-PAC. @@ -17,13 +17,166 @@ """C-PAC pipeline engine utilities.""" from itertools import chain +import os +import subprocess +from typing import Optional, TYPE_CHECKING + +import nibabel as nib from CPAC.func_preproc.func_motion import motion_estimate_filter from CPAC.utils.bids_utils import insert_entity +from CPAC.utils.monitoring import IFLOGGER + +if TYPE_CHECKING: + from CPAC.pipeline.engine import ResourcePool + from CPAC.pipeline.nodeblock import POOL_RESOURCE_MAPPING MOVEMENT_FILTER_KEYS = motion_estimate_filter.outputs +class CrossedVariantsError(Exception): + """Exception raised when crossed variants are found in the inputs.""" + + +def get_shell() -> str: + """Return the path to default shell.""" + shell: Optional[str] = subprocess.getoutput( + f"which $(ps -p {os.getppid()} -o comm=)" + ) + if not shell: + try: + shell = os.environ["_SHELL"] + except KeyError: + msg = "Shell command not found." + raise EnvironmentError(msg) + return shell + + +def find_pixdim4(file_path): + """Find the pixdim4 value of a NIfTI file. + + Parameters + ---------- + file_path : str + Path to the NIfTI file. + + Returns + ------- + float + The pixdim4 value of the NIfTI file. + + Raises + ------ + FileNotFoundError + If the file does not exist. + nibabel.filebasedimages.ImageFileError + If there is an error loading the NIfTI file. + IndexError + If pixdim4 is not found in the header. + """ + if not os.path.isfile(file_path): + error_message = f"File not found: {file_path}" + raise FileNotFoundError(file_path) + + try: + nii = nib.load(file_path) + header = nii.header + pixdim = header.get_zooms() + return pixdim[3] + except nib.filebasedimages.ImageFileError as e: + error_message = f"Error loading the NIfTI file: {e}" + raise nib.filebasedimages.ImageFileError(error_message) + except IndexError as e: + error_message = f"pixdim4 not found in the header: {e}" + raise IndexError(error_message) + + +def update_pixdim4(file_path, new_pixdim4): + """Update the pixdim4 value of a NIfTI file using 3drefit. + + Parameters + ---------- + file_path : str + Path to the NIfTI file. + new_pixdim4 : float + New pixdim4 value to update the NIfTI file with. + + Raises + ------ + FileNotFoundError + If the file does not exist. + subprocess.CalledProcessError + If there is an error running the subprocess. + + Notes + ----- + The pixdim4 value is the Repetition Time (TR) of the NIfTI file. + + """ + if not os.path.isfile(file_path): + error_message = f"File not found: {file_path}" + raise FileNotFoundError(error_message) + + # Print the current pixdim4 value for verification + IFLOGGER.info(f"Updating {file_path} with new pixdim[4] value: {new_pixdim4}") + + # Construct the command to update the pixdim4 value using 3drefit + command = ["3drefit", "-TR", str(new_pixdim4), file_path] + + try: + subprocess.run(command, check=True) + IFLOGGER.info(f"Successfully updated TR to {new_pixdim4} seconds.") + except subprocess.CalledProcessError as e: + error_message = f"Error occurred while updating the file: {e}" + raise subprocess.CalledProcessError(error_message) + + +def validate_outputs(input_bold, RawSource_bold): + """Match pixdim4/TR of the input_bold with RawSource_bold. + + Parameters + ---------- + input_bold : str + Path to the input BOLD file. + RawSource_bold : str + Path to the RawSource BOLD file. + + Returns + ------- + output_bold : str + Path to the output BOLD file. + + Raises + ------ + Exception + If there is an error in finding or updating pixdim4. + """ + try: + output_bold = input_bold + output_pixdim4 = find_pixdim4(output_bold) + source_pixdim4 = find_pixdim4(RawSource_bold) + + if output_pixdim4 != source_pixdim4: + IFLOGGER.info( + "TR mismatch detected between output_bold and RawSource_bold." + ) + IFLOGGER.info(f"output_bold TR: {output_pixdim4} seconds") + IFLOGGER.info(f"RawSource_bold TR: {source_pixdim4} seconds") + IFLOGGER.info( + "Attempting to update the TR of output_bold to match RawSource_bold." + ) + update_pixdim4(output_bold, source_pixdim4) + else: + IFLOGGER.debug("TR match detected between output_bold and RawSource_bold.") + IFLOGGER.debug(f"output_bold TR: {output_pixdim4} seconds") + IFLOGGER.debug(f"RawSource_bold TR: {source_pixdim4} seconds") + return output_bold + except Exception as e: + error_message = f"Error in validating outputs: {e}" + IFLOGGER.error(error_message) + return output_bold + + def name_fork(resource_idx, cfg, json_info, out_dct): """Create and insert entities for forkpoints. @@ -91,7 +244,9 @@ def name_fork(resource_idx, cfg, json_info, out_dct): return resource_idx, out_dct -def present_outputs(outputs: dict, keys: list) -> dict: +def present_outputs( + outputs: "POOL_RESOURCE_MAPPING", keys: list[str] +) -> "POOL_RESOURCE_MAPPING": """ Return the subset of ``outputs`` including only that are present in ``keys``. @@ -105,12 +260,6 @@ def present_outputs(outputs: dict, keys: list) -> dict: NodeBlocks that differ only by configuration options and relevant output keys. - Parameters - ---------- - outputs : dict - - keys : list of str - Returns ------- dict @@ -224,3 +373,54 @@ def _update_resource_idx(resource_idx, out_dct, key, value): resource_idx = insert_entity(resource_idx, key, value) out_dct["filename"] = insert_entity(out_dct["filename"], key, value) return resource_idx, out_dct + + +def find_variants( + pool: "ResourcePool", keys: list | str | tuple +) -> dict[str, dict[str, set[str]]]: + """Find variants in the ResourcePool for the given keys.""" + outputs = {} + if isinstance(keys, str): + try: + return { + keys: { + _k: {str(_v)} + for _k, _v in pool.get_json(keys)["CpacVariant"].items() + } + } + except LookupError: + return {} + for key in keys: + outputs = {**outputs, **find_variants(pool, key)} + return outputs + + +def short_circuit_crossed_variants( + pool: "ResourcePool", inputs: list | str | tuple +) -> None: + """Short-circuit the strategy if crossed variants are found. + + .. image:: https://media1.tenor.com/m/S93jWPGv52gAAAAd/dont-cross-the-streams-egon.gif + :width: 48 + :alt: Don't cross the streams + """ + _variants = find_variants(pool, inputs) + # collect all variant dicts + variant_dicts = list(_variants.values()) + if not variant_dicts: + return + + # only keep keys that exist in all variant dicts + common_keys = set.intersection(*(set(v.keys()) for v in variant_dicts)) + + crossed_variants = {} + for key in common_keys: + values = set() + for variant in variant_dicts: + values.update(variant.get(key, [])) + if len(values) > 1: + crossed_variants[key] = values + + if crossed_variants: + msg = f"Crossed variants found: {crossed_variants}" + raise CrossedVariantsError(msg) diff --git a/CPAC/qc/pipeline.py b/CPAC/qc/pipeline.py index 15d6b35e09..ed3326e51f 100644 --- a/CPAC/qc/pipeline.py +++ b/CPAC/qc/pipeline.py @@ -1,4 +1,22 @@ -import pkg_resources as p +# Copyright (C) 2018-2025 C-PAC Developers + +# This file is part of C-PAC. + +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. + +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . +"""C-PAC quality control pipeline.""" + +from importlib.resources import as_file, files from CPAC.pipeline import nipype_pipeline_engine as pe from CPAC.pipeline.nodeblock import nodeblock @@ -20,7 +38,8 @@ # register color palettes palletes = ["red", "green", "blue", "red_to_blue", "cyan_to_yellow"] for pallete in palletes: - register_pallete(p.resource_filename("CPAC", "qc/colors/%s.txt" % pallete), pallete) + with as_file(files("CPAC").joinpath(f"qc/colors/{pallete}.txt")) as _pallete: + register_pallete(str(_pallete), pallete) @nodeblock( @@ -156,7 +175,7 @@ def qc_brain_extraction(wf, cfg, strat_pool, pipe_num, opt=None): @nodeblock( - name="qc_brain_extraction", + name="qc_T1w_standard", config=["pipeline_setup", "output_directory", "quality_control"], switch=["generate_quality_control_images"], inputs=["space-template_desc-preproc_T1w", "T1w-brain-template"], diff --git a/CPAC/qc/utils.py b/CPAC/qc/utils.py index 5e04296b00..4a60d756f0 100644 --- a/CPAC/qc/utils.py +++ b/CPAC/qc/utils.py @@ -1,4 +1,4 @@ -# Copyright (C) 2013-2024 C-PAC Developers +# Copyright (C) 2013-2025 C-PAC Developers # This file is part of C-PAC. @@ -14,14 +14,15 @@ # You should have received a copy of the GNU Lesser General Public # License along with C-PAC. If not, see . +"""Quality control utilities for C-PAC.""" + +from importlib.resources import as_file, files import os import subprocess import matplotlib as mpl -import numpy import numpy as np from numpy import ma -import pkg_resources as p import nibabel as nib from CPAC.utils.monitoring import IFLOGGER @@ -32,8 +33,7 @@ def generate_qc_pages(qc_dir): - """Generates the QC HTML files populated with the QC images that were - created during the CPAC pipeline run. + """Generate the QC HTML files populated with the QC images that were created during the CPAC pipeline run. This function runs after the pipeline is over. @@ -59,18 +59,19 @@ def generate_qc_pages(qc_dir): ) raise OSError(msg) from os_error - files = [] - for root, _, fs in os.walk(qc_dir): - root = root[len(qc_dir) + 1 :] - files += [os.path.join(root, f) for f in fs] + _files = [] + for _root, _, fs in os.walk(qc_dir): + root = _root[len(qc_dir) + 1 :] + _files += [os.path.join(root, f) for f in fs] - with open(p.resource_filename("CPAC.qc", "data/index.html"), "rb") as f: - qc_content = f.read() - qc_content = qc_content.replace( - b"/*CPAC*/``/*CPAC*/", ("`" + "\n".join(files) + "`").encode() - ) - with open(os.path.join(qc_dir, "index.html"), "wb") as f: - f.write(qc_content) + with as_file(files("CPAC").joinpath("qc/data/index.html")) as _f: + with _f.open("rb") as f: + qc_content = f.read() + qc_content = qc_content.replace( + b"/*CPAC*/``/*CPAC*/", ("`" + "\n".join(_files) + "`").encode() + ) + with open(os.path.join(qc_dir, "index.html"), "wb") as f2: + f2.write(qc_content) def cal_snr_val(measure_file): @@ -229,8 +230,8 @@ def gen_carpet_plt(gm_mask, wm_mask, csf_mask, functional_to_standard, output): def gen_motion_plt(motion_parameters): - """ - Function to Generate Matplotlib plot for motion. + """Generate Matplotlib plot for motion. + Separate plots for Translation and Rotation are generated. Parameters @@ -276,7 +277,7 @@ def gen_motion_plt(motion_parameters): def gen_histogram(measure_file, measure): - """Generates Histogram Image of intensities for a given input nifti file. + """Generate Histogram Image of intensities for a given input nifti file. Parameters ---------- @@ -355,9 +356,7 @@ def gen_histogram(measure_file, measure): def make_histogram(measure_file, measure): - """ - Generates Histogram Image of intensities for a given input - nifti file. + """Generate Histogram Image of intensities for a given input nifti file. Parameters ---------- @@ -416,9 +415,7 @@ def make_histogram(measure_file, measure): def drop_percent(measure_file, percent): - """ - Zeros out voxels in measure files whose intensity doesnt fall in percent - of voxel intensities. + """Zero out voxels in whose intensity doesn't fall in percent of voxel intensities. Parameters ---------- @@ -459,9 +456,7 @@ def drop_percent(measure_file, percent): def get_spacing(across, down, dimension): - """ - Get Spacing in slices to be selected for montage - display varying in given dimension. + """Get spacing in slices for montage display varying in given dimension. Parameters ---------- @@ -493,9 +488,9 @@ def get_spacing(across, down, dimension): def determine_start_and_end(data, direction, percent): - """ - Determine start slice and end slice in data file in - given direction with at least threshold percent of voxels + """Determine start slice and end slice in data file... + + ...in given direction with at least threshold percent of voxels at start and end slices. Parameters @@ -569,7 +564,7 @@ def determine_start_and_end(data, direction, percent): return start, end -def _log_graphing_error(which_montagee: str, image_name: str, error: Exception): +def _log_graphing_error(which_montage: str, image_name: str, error: Exception): IFLOGGER.error( "\n[!] QC Interface: Had a problem with creating the %s montage for %s" "\n\nDetails:%s. This error might occur because of a registration error" @@ -582,8 +577,9 @@ def _log_graphing_error(which_montagee: str, image_name: str, error: Exception): def montage_axial(overlay, underlay, png_name, cbar_name): - """Draws Montage using overlay on Anatomical brain in Axial Direction, - calls make_montage_axial. + """Draw montage using overlay on anatomical brain in axial direction. + + calls :py:func:`make_montage_axial`. Parameters ---------- @@ -775,9 +771,9 @@ def make_montage_axial(overlay, underlay, png_name, cbar_name): def montage_sagittal(overlay, underlay, png_name, cbar_name): - """ - Draws Montage using overlay on Anatomical brain in Sagittal Direction - calls make_montage_sagittal. + """Draw montage using overlay on anatomical brain in sagittal direction. + + calls :py:func:`make_montage_sagittal`. Parameters ---------- @@ -1243,8 +1239,7 @@ def montage_gm_wm_csf_sagittal(overlay_csf, overlay_wm, overlay_gm, underlay, pn def register_pallete(colors_file, cbar_name): - """ - Registers color pallete to matplotlib. + """Register color pallete to matplotlib. Parameters ---------- @@ -1270,8 +1265,7 @@ def register_pallete(colors_file, cbar_name): def resample_1mm(file_): - """ - Calls make_resample_1mm which resamples file to 1mm space. + """Call make_resample_1mm which resamples file to 1mm space. Parameters ---------- @@ -1362,13 +1356,13 @@ def dc(input1, input2): ----- This is a real metric. """ - input1 = numpy.atleast_1d(input1.astype(bool)) - input2 = numpy.atleast_1d(input2.astype(bool)) + input1 = np.atleast_1d(input1.astype(bool)) + input2 = np.atleast_1d(input2.astype(bool)) - intersection = numpy.count_nonzero(input1 & input2) + intersection = np.count_nonzero(input1 & input2) - size_i1 = numpy.count_nonzero(input1) - size_i2 = numpy.count_nonzero(input2) + size_i1 = np.count_nonzero(input1) + size_i2 = np.count_nonzero(input2) try: dc = 2.0 * intersection / float(size_i1 + size_i2) @@ -1403,22 +1397,19 @@ def jc(input1, input2): ----- This is a real metric. """ - input1 = numpy.atleast_1d(input1.astype(bool)) - input2 = numpy.atleast_1d(input2.astype(bool)) + input1 = np.atleast_1d(input1.astype(bool)) + input2 = np.atleast_1d(input2.astype(bool)) - intersection = numpy.count_nonzero(input1 & input2) - union = numpy.count_nonzero(input1 | input2) + intersection = np.count_nonzero(input1 & input2) + union = np.count_nonzero(input1 | input2) return float(intersection) / float(union) def crosscorr(input1, input2): - """ - cross correlation - computer compute cross correction bewteen input mask. - """ - input1 = numpy.atleast_1d(input1.astype(bool)) - input2 = numpy.atleast_1d(input2.astype(bool)) + """Compute cross correction bewteen input masks.""" + input1 = np.atleast_1d(input1.astype(bool)) + input2 = np.atleast_1d(input2.astype(bool)) from scipy.stats.stats import pearsonr @@ -1427,12 +1418,12 @@ def crosscorr(input1, input2): def coverage(input1, input2): """Estimate the coverage between two mask.""" - input1 = numpy.atleast_1d(input1.astype(bool)) - input2 = numpy.atleast_1d(input2.astype(bool)) + input1 = np.atleast_1d(input1.astype(bool)) + input2 = np.atleast_1d(input2.astype(bool)) - intsec = numpy.count_nonzero(input1 & input2) - if numpy.sum(input1) > numpy.sum(input2): - smallv = numpy.sum(input2) + intsec = np.count_nonzero(input1 & input2) + if np.sum(input1) > np.sum(input2): + smallv = np.sum(input2) else: - smallv = numpy.sum(input1) + smallv = np.sum(input1) return float(intsec) / float(smallv) diff --git a/CPAC/qc/xcp.py b/CPAC/qc/xcp.py index 95cb870430..3ee7db7412 100644 --- a/CPAC/qc/xcp.py +++ b/CPAC/qc/xcp.py @@ -1,3 +1,19 @@ +# Copyright (C) 2021-2025 C-PAC Developers + +# This file is part of C-PAC. + +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. + +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . """ Generate XCP-stype quality control files. @@ -61,11 +77,12 @@ from io import BufferedReader import os import re +from typing import Any, Optional from bids.layout import parse_file_entities import numpy as np import pandas as pd -import nibabel as nib +from nibabel import load as nib_load # type: ignore[reportPrivateImportUsage] from nipype.interfaces import afni, fsl from CPAC.generate_motion_statistics.generate_motion_statistics import ( @@ -73,6 +90,7 @@ ImageTo1D, ) from CPAC.pipeline import nipype_pipeline_engine as pe +from CPAC.pipeline.engine import ResourcePool from CPAC.pipeline.nodeblock import nodeblock from CPAC.qc.qcmetrics import regisQ from CPAC.utils.interfaces.function import Function @@ -85,40 +103,27 @@ ] -def _connect_motion(wf, nodes, strat_pool, qc_file, pipe_num): +def _connect_motion( + wf: pe.Workflow, strat_pool: ResourcePool, qc_file: pe.Node, pipe_num: int +) -> pe.Workflow: """ Connect the motion metrics to the workflow. Parameters ---------- - wf : nipype.pipeline.engine.Workflow + wf The workflow to connect the motion metrics to. - nodes : dict - Dictionary of nodes already collected from the strategy pool. - - strat_pool : CPAC.pipeline.engine.ResourcePool + strat_pool The current strategy pool. - qc_file : nipype.pipeline.engine.Node + qc_file A function node with the function ``generate_xcp_qc``. - - pipe_num : int - - Returns - ------- - wf : nipype.pipeline.engine.Workflow """ # pylint: disable=invalid-name, too-many-arguments - try: - nodes = {**nodes, "censor-indices": strat_pool.node_data("censor-indices")} - wf.connect( - nodes["censor-indices"].node, - nodes["censor-indices"].out, - qc_file, - "censor_indices", - ) - except LookupError: + if strat_pool.check_rpool("censor-indices"): + wf.connect_optional(strat_pool, "censor-indices", qc_file, "censor_indices") + else: qc_file.inputs.censor_indices = [] cal_DVARS = pe.Node( ImageTo1D(method="dvars"), @@ -130,31 +135,31 @@ def _connect_motion(wf, nodes, strat_pool, qc_file, pipe_num): cal_DVARS_strip = pe.Node( Function( input_names=["file_1D"], - output_names=["out_file"], + output_names=["out_file", "out_matrix"], function=DVARS_strip_t0, as_module=True, ), name=f"cal_DVARS_strip_{pipe_num}", ) + motion_name = "desc-movementParametersUnfiltered_motion" + if not strat_pool.check_rpool(motion_name): + motion_name = "desc-movementParameters_motion" + wf.connect_optional(strat_pool, "desc-preproc_bold", cal_DVARS, "in_file") + wf.connect_optional(strat_pool, "space-bold_desc-brain_mask", cal_DVARS, "mask") + wf.connect_optional(strat_pool, motion_name, qc_file, "movement_parameters") + for resource in motion_params: + if not resource.endswith("_motion"): + wf.connect_optional( + strat_pool, resource, qc_file, resource.replace("-", "_") + ) wf.connect( [ + (cal_DVARS, cal_DVARS_strip, [("out_file", "file_1D")]), ( - nodes["desc-preproc_bold"].node, - cal_DVARS, - [(nodes["desc-preproc_bold"].out, "in_file")], - ), - ( - nodes["space-bold_desc-brain_mask"].node, - cal_DVARS, - [(nodes["space-bold_desc-brain_mask"].out, "mask")], + cal_DVARS_strip, + qc_file, + [("out_file", "dvars_after_path"), ("out_matrix", "dvars_after")], ), - (cal_DVARS, cal_DVARS_strip, [("out_file", "file_1D")]), - (cal_DVARS_strip, qc_file, [("out_file", "dvars_after")]), - *[ - (nodes[node].node, qc_file, [(nodes[node].out, node.replace("-", "_"))]) - for node in motion_params - if node in nodes - ], ] ) return wf @@ -176,83 +181,87 @@ def dvcorr(dvars, fdj): # This function is for a function node for which # Nipype will connect many other nodes as inputs def generate_xcp_qc( # noqa: PLR0913 - sub, - ses, - task, - run, - desc, - regressors, - bold2t1w_mask, - t1w_mask, - bold2template_mask, - template_mask, - original_func, - final_func, - movement_parameters, - dvars, - censor_indices, - framewise_displacement_jenkinson, - dvars_after, - template, -): + sub: str, + ses: str, + task: str, + run: str | int, + desc: str, + regressors: Optional[str], + bold2t1w_mask: Optional[str], + t1w_mask: Optional[str], + bold2template_mask: Optional[str], + template_mask: Optional[str], + original_func: Optional[str], + final_func: Optional[str], + movement_parameters: Optional[str], + dvars: Optional[str], + censor_indices: Optional[list[int]], + framewise_displacement_jenkinson: Optional[str], + dvars_after: Optional[np.ndarray], + dvars_after_path: Optional[str], + template: Optional[str], +) -> str: """ Generate an RBC-style QC CSV. Parameters ---------- - sub : str + sub subject ID - ses : str + ses session ID - task : str + task task ID - run : str or int + run run ID - desc : str + desc description string - regressors : str + regressors 'Name' of regressors in fork - original_func : str + original_func path to original 'bold' image - final_bold : str + final_bold path to 'space-template_desc-preproc_bold' image - bold2t1w_mask : str + bold2t1w_mask path to bold-to-T1w transform applied to space-bold_desc-brain_mask with space-T1w_desc-brain_mask reference - t1w_mask : str + t1w_mask path to space-T1w_desc-brain_mask - bold2template_mask : str + bold2template_mask path to space-template_desc-bold_mask - template_mask : str + template_mask path to T1w-brain-template-mask or EPI-template-mask - movement_parameters: str + movement_parameters path to movement parameters - dvars : str + dvars path to DVARS before motion correction - censor_indices : list + censor_indices list of indices of censored volumes - framewise_displacement_jenkinson : str + framewise_displacement_jenkinson path to framewise displacement (Jenkinson) before motion correction - dvars_after : str - path to DVARS on final 'bold' image + dvars_after + DVARS matrix for final 'bold' image - template : str + dvars_after_path + path to DVARS matrix for final 'bold' image + + template path to registration template Returns @@ -260,90 +269,131 @@ def generate_xcp_qc( # noqa: PLR0913 str path to space-template_desc-xcp_quality TSV """ - columns = ( - "sub,ses,task,run,desc,regressors,space,meanFD,relMeansRMSMotion," - "relMaxRMSMotion,meanDVInit,meanDVFinal,nVolCensored,nVolsRemoved," - "motionDVCorrInit,motionDVCorrFinal,coregDice,coregJaccard," - "coregCrossCorr,coregCoverage,normDice,normJaccard,normCrossCorr," - "normCoverage".split(",") - ) - images = { - "original_func": nib.load(original_func), - "final_func": nib.load(final_func), + key: nib_load(image) + for key, image in [("original_func", original_func), ("final_func", final_func)] + if image } - # `sub` through `space` - from_bids = { - "sub": sub, - "ses": ses, - "task": task, - "run": run, - "desc": desc, - "regressors": regressors, - "space": os.path.basename(template).split(".", 1)[0].split("_", 1)[0], - } - if from_bids["space"].startswith("tpl-"): - from_bids["space"] = from_bids["space"][4:] - - # `nVolCensored` & `nVolsRemoved` - n_vols_censored = len(censor_indices) if censor_indices is not None else "unknown" - shape_params = { - "nVolCensored": n_vols_censored, - "nVolsRemoved": images["original_func"].shape[3] - - images["final_func"].shape[3], - } + qc_dict: dict[str, Any] = {} + """Quality control dictionary to be converted to a DataFrame.""" - if isinstance(final_func, BufferedReader): - final_func = final_func.name - qc_filepath = os.path.join(os.getcwd(), "xcpqc.tsv") + columns: list[str] = [] + """Header for the quality control DataFrame.""" - desc_span = re.search(r"_desc-.*_", final_func) - if desc_span: - desc_span = desc_span.span() - final_func = "_".join([final_func[: desc_span[0]], final_func[desc_span[1] :]]) - del desc_span - - # `meanFD (Jenkinson)` - power_params = {"meanFD": np.mean(np.loadtxt(framewise_displacement_jenkinson))} - - # `relMeansRMSMotion` & `relMaxRMSMotion` - mot = np.genfromtxt(movement_parameters).T - # Relative RMS of translation - rms = np.sqrt(mot[3] ** 2 + mot[4] ** 2 + mot[5] ** 2) - rms_params = {"relMeansRMSMotion": [np.mean(rms)], "relMaxRMSMotion": [np.max(rms)]} - - # `meanDVInit` & `meanDVFinal` - meanDV = {"meanDVInit": np.mean(np.loadtxt(dvars))} - try: - meanDV["motionDVCorrInit"] = dvcorr(dvars, framewise_displacement_jenkinson) - except ValueError as value_error: - meanDV["motionDVCorrInit"] = f"ValueError({value_error!s})" - meanDV["meanDVFinal"] = np.mean(np.loadtxt(dvars_after)) - try: - meanDV["motionDVCorrFinal"] = dvcorr( - dvars_after, framewise_displacement_jenkinson + # `sub` through `space` + from_bids: dict[str, Any] = { + _k: _v + for _k, _v in { + "sub": sub, + "ses": ses, + "task": task, + "run": run, + "desc": desc, + "regressors": regressors, + "space": os.path.basename(template).split(".", 1)[0].split("_", 1)[0] + if template + else None, + }.items() + if _v is not None + } + columns.extend(["sub", "ses", "task", "run", "desc", "regressors"]) + if from_bids["space"] is not None: + if from_bids["space"].startswith("tpl-"): + from_bids["space"] = from_bids["space"][4:] + columns.append("space") + qc_dict = {**qc_dict, **from_bids} + + if framewise_displacement_jenkinson is not None: + # `meanFD (Jenkinson)` + power_params = {"meanFD": np.mean(np.loadtxt(framewise_displacement_jenkinson))} + qc_dict = {**qc_dict, **power_params} + columns.append("meanFD") + + if movement_parameters is not None: + # `relMeansRMSMotion` & `relMaxRMSMotion` + mot = np.genfromtxt(movement_parameters).T + # Relative RMS of translation + rms = np.sqrt(mot[3] ** 2 + mot[4] ** 2 + mot[5] ** 2) + rms_params = { + "relMeansRMSMotion": [np.mean(rms)], + "relMaxRMSMotion": [np.max(rms)], + } + qc_dict = {**qc_dict, **rms_params} + columns.extend(list(rms_params.keys())) + + if dvars is not None: + # `meanDVInit` & `meanDVFinal` + meanDV = {"meanDVInit": np.mean(np.loadtxt(dvars))} + try: + meanDV["motionDVCorrInit"] = dvcorr(dvars, framewise_displacement_jenkinson) + except ValueError as value_error: + meanDV["motionDVCorrInit"] = f"ValueError({value_error!s})" + meanDV["meanDVFinal"] = np.mean(dvars_after) + try: + meanDV["motionDVCorrFinal"] = dvcorr( + dvars_after_path, framewise_displacement_jenkinson + ) + except ValueError as value_error: + meanDV["motionDVCorrFinal"] = f"ValueError({value_error!s})" + qc_dict = {**qc_dict, **meanDV} + columns.extend(list(meanDV.keys())) + + if censor_indices is not None: + # `nVolCensored` & `nVolsRemoved` + n_vols_censored = ( + len(censor_indices) if censor_indices is not None else "unknown" ) - except ValueError as value_error: - meanDV["motionDVCorrFinal"] = f"ValueError({value_error!s})" - - # Overlap - overlap_params = regisQ( - bold2t1w_mask=bold2t1w_mask, - t1w_mask=t1w_mask, - bold2template_mask=bold2template_mask, - template_mask=template_mask, - ) + shape_params = { + "nVolCensored": n_vols_censored, + "nVolsRemoved": images["original_func"].shape[3] + - images["final_func"].shape[3], + } + qc_dict = {**qc_dict, **shape_params} + if "motionDVCorrFinal" in columns: + columns.insert(-2, "meanDVInit") + columns.insert(-2, "meanDVFinal") + columns.extend(["motionDVCorrInit", "motionDVCorrFinal"]) + else: + columns.extend(list(shape_params.keys())) + + if final_func is not None: + if isinstance(final_func, BufferedReader): + final_func = final_func.name + + desc_span = re.search(r"_desc-.*_", final_func) if final_func else None + if desc_span: + desc_span = desc_span.span() + assert final_func is not None + final_func = "_".join( + [final_func[: desc_span[0]], final_func[desc_span[1] :]] + ) + del desc_span + + if all( + _var is not None + for _var in [bold2t1w_mask, t1w_mask, bold2template_mask, template_mask] + ): + # `coregDice`, `coregJaccard`, `coregCrossCorr`, `coregCoverage` + coreg_params = regisQ( + bold2t1w_mask=bold2t1w_mask, + t1w_mask=t1w_mask, + bold2template_mask=bold2template_mask, + template_mask=template_mask, + ) + # Overlap + overlap_params = regisQ( + bold2t1w_mask=bold2t1w_mask, + t1w_mask=t1w_mask, + bold2template_mask=bold2template_mask, + template_mask=template_mask, + ) + qc_dict = {**qc_dict, **coreg_params, **overlap_params} + columns.extend([*list(coreg_params.keys()), *list(overlap_params.keys())]) - qc_dict = { - **from_bids, - **power_params, - **rms_params, - **shape_params, - **overlap_params, - **meanDV, - } df = pd.DataFrame(qc_dict, columns=columns) + + qc_filepath = os.path.join(os.getcwd(), "xcpqc.tsv") df.to_csv(qc_filepath, sep="\t", index=False) return qc_filepath @@ -439,7 +489,7 @@ def get_entity_part(key): "space-bold_desc-brain_mask", ["T1w-brain-template-mask", "EPI-template-mask"], ["space-template_desc-bold_mask", "space-EPItemplate_desc-bold_mask"], - "regressors", + "desc-confounds_timeseries", ["T1w-brain-template-funcreg", "EPI-brain-template-funcreg"], [ "desc-movementParametersUnfiltered_motion", @@ -458,7 +508,7 @@ def qc_xcp(wf, cfg, strat_pool, pipe_num, opt=None): # pylint: disable=invalid-name, unused-argument if cfg[ "nuisance_corrections", "2-nuisance_regression", "run" - ] and not strat_pool.check_rpool("regressors"): + ] and not strat_pool.check_rpool("desc-confounds_timeseries"): return wf, {} bids_info = pe.Node( Function( @@ -491,6 +541,7 @@ def qc_xcp(wf, cfg, strat_pool, pipe_num, opt=None): "censor_indices", "regressors", "framewise_displacement_jenkinson", + "dvars_after_path", "dvars_after", ], output_names=["qc_file"], @@ -501,8 +552,8 @@ def qc_xcp(wf, cfg, strat_pool, pipe_num, opt=None): ) qc_file.inputs.desc = "preproc" qc_file.inputs.regressors = ( - strat_pool.node_data("regressors") - .node.name.split("regressors_")[-1][::-1] + strat_pool.node_data("desc-confounds_timeseries") + .node.name.split("desc-confounds_timeseries_")[-1][::-1] .split("_", 1)[-1][::-1] ) bold_to_T1w_mask = pe.Node( @@ -510,31 +561,6 @@ def qc_xcp(wf, cfg, strat_pool, pipe_num, opt=None): name=f"binarize_bold_to_T1w_mask_{pipe_num}", op_string="-bin ", ) - nodes = { - key: strat_pool.node_data(key) - for key in [ - "bold", - "desc-preproc_bold", - "max-displacement", - "scan", - "space-bold_desc-brain_mask", - "space-T1w_desc-brain_mask", - "space-T1w_sbref", - "space-template_desc-preproc_bold", - "subject", - *motion_params, - ] - if strat_pool.check_rpool(key) - } - nodes["bold2template_mask"] = strat_pool.node_data( - ["space-template_desc-bold_mask", "space-EPItemplate_desc-bold_mask"] - ) - nodes["template_mask"] = strat_pool.node_data( - ["T1w-brain-template-mask", "EPI-template-mask"] - ) - nodes["template"] = strat_pool.node_data( - ["T1w-brain-template-funcreg", "EPI-brain-template-funcreg"] - ) resample_bold_mask_to_template = pe.Node( afni.Resample(), name=f"resample_bold_mask_to_anat_res_{pipe_num}", @@ -542,44 +568,45 @@ def qc_xcp(wf, cfg, strat_pool, pipe_num, opt=None): mem_x=(0.0115, "in_file", "t"), ) resample_bold_mask_to_template.inputs.outputtype = "NIFTI_GZ" - wf = _connect_motion(wf, nodes, strat_pool, qc_file, pipe_num=pipe_num) + wf: pe.Workflow = _connect_motion(wf, strat_pool, qc_file, pipe_num=pipe_num) + if not hasattr(wf, "connect_optional"): + setattr(wf, "connect_optional", pe.Workflow.connect_optional) + + for key in ["subject", "scan"]: + wf.connect_optional(strat_pool, key, bids_info, key) + wf.connect_optional(strat_pool, "space-T1w_sbref", bold_to_T1w_mask, "in_file") + wf.connect_optional(strat_pool, "space-T1w_desc-brain_mask", qc_file, "t1w_mask") + wf.connect_optional( + strat_pool, + ["T1w-brain-template-mask", "EPI-template-mask"], + qc_file, + "template_mask", + ) + wf.connect_optional( + strat_pool, + ["T1w-brain-template-mask", "EPI-template-mask"], + resample_bold_mask_to_template, + "master", + ) + wf.connect_optional(strat_pool, "bold", qc_file, "original_func") + wf.connect_optional( + strat_pool, "space-template_desc-preproc_bold", qc_file, "final_func" + ) + wf.connect_optional( + strat_pool, + ["T1w-brain-template-funcreg", "EPI-brain-template-funcreg"], + qc_file, + "template", + ) + wf.connect_optional( + strat_pool, + ["space-template_desc-bold_mask", "space-EPItemplate_desc-bold_mask"], + resample_bold_mask_to_template, + "in_file", + ) wf.connect( [ - (nodes["subject"].node, bids_info, [(nodes["subject"].out, "subject")]), - (nodes["scan"].node, bids_info, [(nodes["scan"].out, "scan")]), - ( - nodes["space-T1w_sbref"].node, - bold_to_T1w_mask, - [(nodes["space-T1w_sbref"].out, "in_file")], - ), - ( - nodes["space-T1w_desc-brain_mask"].node, - qc_file, - [(nodes["space-T1w_desc-brain_mask"].out, "t1w_mask")], - ), (bold_to_T1w_mask, qc_file, [("out_file", "bold2t1w_mask")]), - ( - nodes["template_mask"].node, - qc_file, - [(nodes["template_mask"].out, "template_mask")], - ), - (nodes["bold"].node, qc_file, [(nodes["bold"].out, "original_func")]), - ( - nodes["space-template_desc-preproc_bold"].node, - qc_file, - [(nodes["space-template_desc-preproc_bold"].out, "final_func")], - ), - (nodes["template"].node, qc_file, [(nodes["template"].out, "template")]), - ( - nodes["template_mask"].node, - resample_bold_mask_to_template, - [(nodes["template_mask"].out, "master")], - ), - ( - nodes["bold2template_mask"].node, - resample_bold_mask_to_template, - [(nodes["bold2template_mask"].out, "in_file")], - ), ( resample_bold_mask_to_template, qc_file, diff --git a/CPAC/registration/registration.py b/CPAC/registration/registration.py index da63e694e4..7b4f50be78 100644 --- a/CPAC/registration/registration.py +++ b/CPAC/registration/registration.py @@ -1,4 +1,4 @@ -# Copyright (C) 2012-2024 C-PAC Developers +# Copyright (C) 2012-2025 C-PAC Developers # This file is part of C-PAC. @@ -17,16 +17,17 @@ # pylint: disable=too-many-lines,ungrouped-imports,wrong-import-order """Workflows for registration.""" -from typing import Optional +from typing import Literal, Optional, TYPE_CHECKING from voluptuous import RequiredFieldInvalid from nipype.interfaces import afni, ants, c3, fsl, utility as util from nipype.interfaces.afni import utils as afni_utils from CPAC.anat_preproc.lesion_preproc import create_lesion_preproc +from CPAC.func_preproc.func_preproc import fsl_afni_subworkflow from CPAC.func_preproc.utils import chunk_ts, split_ts_chunks from CPAC.pipeline import nipype_pipeline_engine as pe -from CPAC.pipeline.nodeblock import nodeblock +from CPAC.pipeline.nodeblock import nodeblock, NODEBLOCK_RETURN from CPAC.registration.utils import ( change_itk_transform_type, check_transforms, @@ -41,17 +42,20 @@ ) from CPAC.utils.interfaces import Function from CPAC.utils.interfaces.fsl import Merge as fslMerge -from CPAC.utils.utils import check_prov_for_motion_tool, check_prov_for_regtool + +if TYPE_CHECKING: + from CPAC.pipeline.engine import ResourcePool + from CPAC.utils.configuration import Configuration def apply_transform( - wf_name, - reg_tool, - time_series=False, - multi_input=False, - num_cpus=1, - num_ants_cores=1, -): + wf_name: str, + reg_tool: Literal["ants", "fsl"], + time_series: bool = False, + multi_input: bool = False, + num_cpus: int = 1, + num_ants_cores: int = 1, +) -> pe.Workflow: """Apply transform.""" if not reg_tool: msg = ( @@ -97,7 +101,7 @@ def apply_transform( ) apply_warp.inputs.dimension = 3 - apply_warp.interface.num_threads = int(num_ants_cores) + apply_warp.inputs.num_threads = int(num_ants_cores) if time_series: apply_warp.inputs.input_image_type = 3 @@ -707,6 +711,12 @@ def create_register_func_to_anat( inputspec.interp : string Type of interpolation to use ('trilinear' or 'nearestneighbour' or 'sinc') + inputspec.ref_weight : string (nifti file) + Reference weight image for registration + inputspec.fieldmap : string (nifti file) + Field map image for registration + inputspec.fieldmapmask : string (nifti file) + Field map mask image for registration Workflow Outputs:: @@ -719,7 +729,15 @@ def create_register_func_to_anat( inputspec = pe.Node( util.IdentityInterface( - fields=["func", "anat", "dof", "interp", "fieldmap", "fieldmapmask"] + fields=[ + "func", + "anat", + "dof", + "interp", + "fieldmap", + "fieldmapmask", + "ref_weight", + ] ), name="inputspec", ) @@ -750,6 +768,7 @@ def create_register_func_to_anat( linear_reg.inputs.dof = config.registration_workflows["functional_registration"][ "coregistration" ]["dof"] + if ( config.registration_workflows["functional_registration"]["coregistration"][ "arguments" @@ -1453,9 +1472,15 @@ def create_wf_calculate_ants_warp( def FSL_registration_connector( - wf_name, cfg, orig="T1w", opt=None, symmetric=False, template="T1w" -): + wf_name: str, + cfg: "Configuration", + orig: str = "T1w", + opt: Literal["FSL", "FSL-linear"] = "FSL", + symmetric: bool = False, + template: str = "T1w", +) -> NODEBLOCK_RETURN: """Transform raw data to template with FSL.""" + assert opt in ["FSL", "FSL-linear"] wf = pe.Workflow(name=wf_name) inputNode = pe.Node( @@ -1484,90 +1509,85 @@ def FSL_registration_connector( tmpl = "" if template == "EPI": tmpl = "EPI" + flirt_reg_anat_mni = create_fsl_flirt_linear_reg(f"anat_mni_flirt_register{symm}") - if opt in ("FSL", "FSL-linear"): - flirt_reg_anat_mni = create_fsl_flirt_linear_reg( - f"anat_mni_flirt_register{symm}" - ) + # Input registration parameters + wf.connect(inputNode, "interpolation", flirt_reg_anat_mni, "inputspec.interp") - # Input registration parameters - wf.connect(inputNode, "interpolation", flirt_reg_anat_mni, "inputspec.interp") + wf.connect(inputNode, "input_brain", flirt_reg_anat_mni, "inputspec.input_brain") - wf.connect( - inputNode, "input_brain", flirt_reg_anat_mni, "inputspec.input_brain" - ) + wf.connect( + inputNode, + "reference_brain", + flirt_reg_anat_mni, + "inputspec.reference_brain", + ) - wf.connect( - inputNode, - "reference_brain", - flirt_reg_anat_mni, - "inputspec.reference_brain", - ) + write_lin_composite_xfm = pe.Node( + interface=fsl.ConvertWarp(), name=f"fsl_lin-warp_to_nii{symm}" + ) - write_lin_composite_xfm = pe.Node( - interface=fsl.ConvertWarp(), name=f"fsl_lin-warp_to_nii{symm}" - ) + wf.connect(inputNode, "reference_brain", write_lin_composite_xfm, "reference") - wf.connect(inputNode, "reference_brain", write_lin_composite_xfm, "reference") + wf.connect( + flirt_reg_anat_mni, + "outputspec.linear_xfm", + write_lin_composite_xfm, + "premat", + ) - wf.connect( - flirt_reg_anat_mni, - "outputspec.linear_xfm", - write_lin_composite_xfm, - "premat", - ) + write_invlin_composite_xfm = pe.Node( + interface=fsl.ConvertWarp(), name=f"fsl_invlin-warp_to_nii{symm}" + ) - write_invlin_composite_xfm = pe.Node( - interface=fsl.ConvertWarp(), name=f"fsl_invlin-warp_to_nii{symm}" - ) + wf.connect(inputNode, "reference_brain", write_invlin_composite_xfm, "reference") - wf.connect( - inputNode, "reference_brain", write_invlin_composite_xfm, "reference" - ) + wf.connect( + flirt_reg_anat_mni, + "outputspec.invlinear_xfm", + write_invlin_composite_xfm, + "premat", + ) - wf.connect( + outputs = { + f"space-{sym}template_desc-preproc_{orig}": ( flirt_reg_anat_mni, - "outputspec.invlinear_xfm", + "outputspec.output_brain", + ), + f"from-{orig}_to-{sym}{tmpl}template_mode-image_desc-linear_xfm": ( + write_lin_composite_xfm, + "out_file", + ), + f"from-{sym}{tmpl}template_to-{orig}_mode-image_desc-linear_xfm": ( write_invlin_composite_xfm, - "premat", - ) + "out_file", + ), + f"from-{orig}_to-{sym}{tmpl}template_mode-image_xfm": ( + write_lin_composite_xfm, + "out_file", + ), + } - outputs = { - f"space-{sym}template_desc-preproc_{orig}": ( - flirt_reg_anat_mni, - "outputspec.output_brain", - ), - f"from-{orig}_to-{sym}{tmpl}template_mode-image_desc-linear_xfm": ( - write_lin_composite_xfm, - "out_file", - ), - f"from-{sym}{tmpl}template_to-{orig}_mode-image_desc-linear_xfm": ( - write_invlin_composite_xfm, - "out_file", - ), - f"from-{orig}_to-{sym}{tmpl}template_mode-image_xfm": ( - write_lin_composite_xfm, - "out_file", - ), - } + if cfg.registration_workflows["sink_native_transforms"]: + outputs.update( + { + f"from-{orig}_to-{sym}{tmpl}template_mode-image_desc-flirt_xfm": ( + flirt_reg_anat_mni, + "outputspec.linear_xfm", + ), + f"from-{sym}{tmpl}template_to-{orig}_mode-image_desc-flirt_xfm": ( + flirt_reg_anat_mni, + "outputspec.invlinear_xfm", + ), + } + ) if opt == "FSL": - if ( - cfg.registration_workflows["anatomical_registration"]["registration"][ - "FSL-FNIRT" - ]["ref_resolution"] - == cfg.registration_workflows["anatomical_registration"][ - "resolution_for_anat" - ] - ): - fnirt_reg_anat_mni = create_fsl_fnirt_nonlinear_reg( - f"anat_mni_fnirt_register{symm}" - ) - else: - fnirt_reg_anat_mni = create_fsl_fnirt_nonlinear_reg_nhp( - f"anat_mni_fnirt_register{symm}" - ) - + fnirt_reg_anat_mni = ( + create_fsl_fnirt_nonlinear_reg_nhp + if cfg["pipeline_setup", "organism"] == "non-human primate" + else create_fsl_fnirt_nonlinear_reg + )(f"anat_mni_fnirt_register{symm}") wf.connect( inputNode, "input_brain", fnirt_reg_anat_mni, "inputspec.input_brain" ) @@ -1602,57 +1622,41 @@ def FSL_registration_connector( inputNode, "fnirt_config", fnirt_reg_anat_mni, "inputspec.fnirt_config" ) - if ( - cfg.registration_workflows["anatomical_registration"]["registration"][ - "FSL-FNIRT" - ]["ref_resolution"] - == cfg.registration_workflows["anatomical_registration"][ - "resolution_for_anat" - ] - ): - # NOTE: this is an UPDATE because of the opt block above - added_outputs = { - f"space-{sym}template_desc-preproc_{orig}": ( - fnirt_reg_anat_mni, - "outputspec.output_brain", - ), - f"from-{orig}_to-{sym}{tmpl}template_mode-image_xfm": ( - fnirt_reg_anat_mni, - "outputspec.nonlinear_xfm", - ), - } - outputs.update(added_outputs) - else: - # NOTE: this is an UPDATE because of the opt block above - added_outputs = { - f"space-{sym}template_desc-preproc_{orig}": ( - fnirt_reg_anat_mni, - "outputspec.output_brain", - ), - f"space-{sym}template_desc-head_{orig}": ( - fnirt_reg_anat_mni, - "outputspec.output_head", - ), - f"space-{sym}template_desc-{orig}_mask": ( - fnirt_reg_anat_mni, - "outputspec.output_mask", - ), - f"space-{sym}template_desc-T1wT2w_biasfield": ( - fnirt_reg_anat_mni, - "outputspec.output_biasfield", - ), - f"from-{orig}_to-{sym}{tmpl}template_mode-image_xfm": ( - fnirt_reg_anat_mni, - "outputspec.nonlinear_xfm", - ), - f"from-{orig}_to-{sym}{tmpl}template_mode-image_warp": ( - fnirt_reg_anat_mni, - "outputspec.nonlinear_warp", - ), - } - outputs.update(added_outputs) + # NOTE: this is an UPDATE because of the opt block above + added_outputs = { + f"space-{sym}template_desc-preproc_{orig}": ( + fnirt_reg_anat_mni, + "outputspec.output_brain", + ), + f"from-{orig}_to-{sym}{tmpl}template_mode-image_xfm": ( + fnirt_reg_anat_mni, + "outputspec.nonlinear_xfm", + ), + } + if cfg["pipeline_setup", "organism"] == "non-human primate": + added_outputs.update( + { + f"space-{sym}template_desc-head_{orig}": ( + fnirt_reg_anat_mni, + "outputspec.output_head", + ), + f"space-{sym}template_desc-{'brain' if orig == 'T1w' else orig}_mask": ( + fnirt_reg_anat_mni, + "outputspec.output_mask", + ), + f"space-{sym}template_desc-T1wT2w_biasfield": ( + fnirt_reg_anat_mni, + "outputspec.output_biasfield", + ), + f"from-{orig}_to-{sym}{tmpl}template_mode-image_warp": ( + fnirt_reg_anat_mni, + "outputspec.nonlinear_warp", + ), + } + ) + outputs.update(added_outputs) - return (wf, outputs) + return wf, outputs def ANTs_registration_connector( @@ -1736,7 +1740,7 @@ def ANTs_registration_connector( "ANTs" ]["use_lesion_mask"]: # Create lesion preproc node to apply afni Refit and Resample - lesion_preproc = create_lesion_preproc(wf_name=f"lesion_preproc{symm}") + lesion_preproc = create_lesion_preproc(cfg, wf_name=f"lesion_preproc{symm}") wf.connect(inputNode, "lesion_mask", lesion_preproc, "inputspec.lesion") wf.connect( lesion_preproc, @@ -2080,6 +2084,24 @@ def ANTs_registration_connector( ), } + if cfg.registration_workflows["sink_native_transforms"]: + outputs.update( + { + f"from-{orig}_to-{sym}{tmpl}template_mode-image_desc-initial_xfm": ( + ants_reg_anat_mni, + "outputspec.ants_initial_xfm", + ), + f"from-{orig}_to-{sym}{tmpl}template_mode-image_desc-rigid_xfm": ( + ants_reg_anat_mni, + "outputspec.ants_rigid_xfm", + ), + f"from-{orig}_to-{sym}{tmpl}template_mode-image_desc-affine_xfm": ( + ants_reg_anat_mni, + "outputspec.ants_affine_xfm", + ), + } + ) + return (wf, outputs) @@ -2269,25 +2291,31 @@ def bold_to_T1template_xfm_connector( "template-ref-mask", ], outputs={ - "space-template_desc-preproc_T1w": {"Template": "T1w-brain-template"}, - "space-template_desc-head_T1w": {"Template": "T1w-template"}, - "space-template_desc-T1w_mask": {"Template": "T1w-template"}, - "space-template_desc-T1wT2w_biasfield": {"Template": "T1w-template"}, - "from-T1w_to-template_mode-image_desc-linear_xfm": {"Template": "T1w-template"}, - "from-template_to-T1w_mode-image_desc-linear_xfm": {"Template": "T1w-template"}, - "from-T1w_to-template_mode-image_xfm": {"Template": "T1w-template"}, - "from-T1w_to-template_mode-image_warp": {"Template": "T1w-template"}, - "from-longitudinal_to-template_mode-image_desc-linear_xfm": { - "Template": "T1w-template" - }, - "from-template_to-longitudinal_mode-image_desc-linear_xfm": { - "Template": "T1w-template" + **{ + key: {"Template": "T1w-template"} + for key in [ + "space-template_desc-head_T1w", + "space-template_desc-brain_mask", + "space-template_desc-T1wT2w_biasfield", + "from-T1w_to-template_mode-image_desc-linear_xfm", + "from-template_to-T1w_mode-image_desc-linear_xfm", + "from-T1w_to-template_mode-image_xfm", + "from-T1w_to-template_mode-image_warp", + "from-longitudinal_to-template_mode-image_desc-linear_xfm", + "from-template_to-longitudinal_mode-image_desc-linear_xfm", + "from-longitudinal_to-template_mode-image_xfm", + "from-T1w_to-template_mode-image_desc-flirt_xfm", + "from-template_to-T1w_mode-image_desc-flirt_xfm", + "from-longitudinal_to-template_mode-image_desc-flirt_xfm", + "from-template_to-longitudinal_mode-image_desc-flirt_xfm", + ] }, - "from-longitudinal_to-template_mode-image_xfm": {"Template": "T1w-template"}, + "space-template_desc-preproc_T1w": {"Template": "T1w-brain-template"}, }, ) def register_FSL_anat_to_template(wf, cfg, strat_pool, pipe_num, opt=None): """Register T1w to template with FSL.""" + assert opt in ["FSL", "FSL-linear"] fsl, outputs = FSL_registration_connector( f"register_{opt}_anat_to_template_{pipe_num}", cfg, orig="T1w", opt=opt ) @@ -2306,22 +2334,17 @@ def register_FSL_anat_to_template(wf, cfg, strat_pool, pipe_num, opt=None): node, out = connect wf.connect(node, out, fsl, "inputspec.input_brain") - if ( - cfg.registration_workflows["anatomical_registration"]["registration"][ - "FSL-FNIRT" - ]["ref_resolution"] - == cfg.registration_workflows["anatomical_registration"]["resolution_for_anat"] - ): - node, out = strat_pool.get_data("T1w-brain-template") + if cfg["pipeline_setup", "organism"] == "non-human primate": + node, out = strat_pool.get_data("FNIRT-T1w-brain-template") wf.connect(node, out, fsl, "inputspec.reference_brain") - node, out = strat_pool.get_data("T1w-template") + node, out = strat_pool.get_data("FNIRT-T1w-template") wf.connect(node, out, fsl, "inputspec.reference_head") else: - node, out = strat_pool.get_data("FNIRT-T1w-brain-template") + node, out = strat_pool.get_data("T1w-brain-template") wf.connect(node, out, fsl, "inputspec.reference_brain") - node, out = strat_pool.get_data("FNIRT-T1w-template") + node, out = strat_pool.get_data("T1w-template") wf.connect(node, out, fsl, "inputspec.reference_head") node, out = strat_pool.get_data( @@ -2333,17 +2356,15 @@ def register_FSL_anat_to_template(wf, cfg, strat_pool, pipe_num, opt=None): wf.connect(node, out, fsl, "inputspec.reference_mask") if "space-longitudinal" in brain: - for key in outputs.keys(): + for key in list(outputs.keys()): if "from-T1w" in key: new_key = key.replace("from-T1w", "from-longitudinal") outputs[new_key] = outputs[key] - del outputs[key] if "to-T1w" in key: new_key = key.replace("to-T1w", "to-longitudinal") outputs[new_key] = outputs[key] - del outputs[key] - return (wf, outputs) + return wf, outputs @nodeblock( @@ -2362,31 +2383,38 @@ def register_FSL_anat_to_template(wf, cfg, strat_pool, pipe_num, opt=None): "dilated-symmetric-brain-mask", ], outputs={ - "space-symtemplate_desc-preproc_T1w": { - "Template": "T1w-brain-template-symmetric" - }, - "from-T1w_to-symtemplate_mode-image_desc-linear_xfm": { - "Template": "T1w-template-symmetric" - }, - "from-symtemplate_to-T1w_mode-image_desc-linear_xfm": { - "Template": "T1w-template-symmetric" - }, - "from-T1w_to-symtemplate_mode-image_xfm": { - "Template": "T1w-template-symmetric" - }, - "from-longitudinal_to-symtemplate_mode-image_desc-linear_xfm": { - "Template": "T1w-template-symmetric" - }, - "from-symtemplate_to-longitudinal_mode-image_desc-linear_xfm": { - "Template": "T1w-template-symmetric" + **{ + f"space-symtemplate_desc-{suffix}": { + "Template": "T1w-brain-template-symmetric" + } + for suffix in [ + *[f"{desc}_T1w" for desc in ["brain", "preproc"]], + "brain_mask", + ] }, - "from-longitudinal_to-symtemplate_mode-image_xfm": { - "Template": "T1w-template-symmetric" + **{ + output: {"Template": "T1w-template-symmetric"} + for output in [ + "space-symtemplate_desc-head_T1w", + "from-T1w_to-symtemplate_mode-image_desc-linear_xfm", + "from-symtemplate_to-T1w_mode-image_desc-linear_xfm", + "from-T1w_to-symtemplate_mode-image_warp", + "from-T1w_to-symtemplate_mode-image_xfm", + "from-longitudinal_to-symtemplate_mode-image_desc-linear_xfm", + "from-symtemplate_to-longitudinal_mode-image_desc-linear_xfm", + "from-longitudinal_to-symtemplate_mode-image_xfm", + "space-symtemplate_desc-T1wT2w_biasfield", + "from-T1w_to-symtemplate_mode-image_desc-flirt_xfm", + "from-symtemplate_to-T1w_mode-image_desc-flirt_xfm", + "from-longitudinal_to-symtemplate_mode-image_desc-flirt_xfm", + "from-symtemplate_to-longitudinal_mode-image_desc-flirt_xfm", + ] }, }, ) def register_symmetric_FSL_anat_to_template(wf, cfg, strat_pool, pipe_num, opt=None): """Register T1w to symmetric template with FSL.""" + assert opt in ["FSL", "FSL-linear"] fsl, outputs = FSL_registration_connector( f"register_{opt}_anat_to_template_symmetric_{pipe_num}", cfg, @@ -2424,7 +2452,7 @@ def register_symmetric_FSL_anat_to_template(wf, cfg, strat_pool, pipe_num, opt=N wf.connect(node, out, fsl, "inputspec.reference_mask") if "space-longitudinal" in brain: - for key in outputs.keys(): + for key in list(outputs.keys()): if "from-T1w" in key: new_key = key.replace("from-T1w", "from-longitudinal") outputs[new_key] = outputs[key] @@ -2449,18 +2477,22 @@ def register_symmetric_FSL_anat_to_template(wf, cfg, strat_pool, pipe_num, opt=N "EPI-template-mask", ], outputs={ - "space-template_desc-preproc_bold": {"Template": "EPI-template"}, - "from-bold_to-EPItemplate_mode-image_desc-linear_xfm": { - "Template": "EPI-template" - }, - "from-EPItemplate_to-bold_mode-image_desc-linear_xfm": { - "Template": "EPI-template" - }, - "from-bold_to-EPItemplate_mode-image_xfm": {"Template": "EPI-template"}, + **{ + key: {"Template": "EPI-template"} + for key in [ + "space-template_desc-preproc_bold", + "from-bold_to-EPItemplate_mode-image_desc-linear_xfm", + "from-EPItemplate_to-bold_mode-image_desc-linear_xfm", + "from-bold_to-EPItemplate_mode-image_xfm", + "from-bold_to-EPItemplate_mode-image_desc-flirt_xfm", + "from-EPItemplate_to-bold_mode-image_desc-flirt_xfm", + ] + } }, ) def register_FSL_EPI_to_template(wf, cfg, strat_pool, pipe_num, opt=None): """Directly register the mean functional to an EPI template. No T1w involved.""" + assert opt in ["FSL", "FSL-linear"] fsl, outputs = FSL_registration_connector( f"register_{opt}_EPI_to_template_{pipe_num}", cfg, @@ -2524,75 +2556,76 @@ def register_FSL_EPI_to_template(wf, cfg, strat_pool, pipe_num, opt=None): "label-lesion_mask", ], outputs={ - "space-template_desc-preproc_T1w": { - "Description": "The preprocessed T1w brain transformed to " - "template space.", - "Template": "T1w-template", - }, - "from-T1w_to-template_mode-image_desc-linear_xfm": { - "Description": "Linear (affine) transform from T1w native space " - "to T1w-template space.", - "Template": "T1w-template", - }, - "from-template_to-T1w_mode-image_desc-linear_xfm": { - "Description": "Linear (affine) transform from T1w-template space " - "to T1w native space.", - "Template": "T1w-template", - }, - "from-T1w_to-template_mode-image_desc-nonlinear_xfm": { - "Description": "Nonlinear (warp field) transform from T1w native " - "space to T1w-template space.", - "Template": "T1w-template", - }, - "from-template_to-T1w_mode-image_desc-nonlinear_xfm": { - "Description": "Nonlinear (warp field) transform from " - "T1w-template space to T1w native space.", - "Template": "T1w-template", - }, - "from-T1w_to-template_mode-image_xfm": { - "Description": "Composite (affine + warp field) transform from " - "T1w native space to T1w-template space.", - "Template": "T1w-template", - }, - "from-template_to-T1w_mode-image_xfm": { - "Description": "Composite (affine + warp field) transform from " - "T1w-template space to T1w native space.", - "Template": "T1w-template", - }, - "from-longitudinal_to-template_mode-image_desc-linear_xfm": { - "Description": "Linear (affine) transform from " - "longitudinal-template space to T1w-template " - "space.", - "Template": "T1w-template", - }, - "from-template_to-longitudinal_mode-image_desc-linear_xfm": { - "Description": "Linear (affine) transform from T1w-template " - "space to longitudinal-template space.", - "Template": "T1w-template", - }, - "from-longitudinal_to-template_mode-image_desc-nonlinear_xfm": { - "Description": "Nonlinear (warp field) transform from " - "longitudinal-template space to T1w-template " - "space.", - "Template": "T1w-template", - }, - "from-template_to-longitudinal_mode-image_desc-nonlinear_xfm": { - "Description": "Nonlinear (warp field) transform from " - "T1w-template space to longitudinal-template " - "space.", - "Template": "T1w-template", - }, - "from-longitudinal_to-template_mode-image_xfm": { - "Description": "Composite (affine + warp field) transform from " - "longitudinal-template space to T1w-template " - "space.", - "Template": "T1w-template", + **{ + k: {"Description": v, "Template": "T1w-template"} + for k, v in [ + ( + "space-template_desc-preproc_T1w", + "The preprocessed T1w brain transformed to template space.", + ), + ( + "from-T1w_to-template_mode-image_desc-linear_xfm", + "Linear (affine) transform from T1w native space to T1w-template space.", + ), + ( + "from-template_to-T1w_mode-image_desc-linear_xfm", + "Linear (affine) transform from T1w-template space to T1w native space.", + ), + ( + "from-T1w_to-template_mode-image_desc-nonlinear_xfm", + "Nonlinear (warp field) transform from T1w native space to T1w-template space.", + ), + ( + "from-template_to-T1w_mode-image_desc-nonlinear_xfm", + "Nonlinear (warp field) transform from T1w-template space to T1w native space.", + ), + ( + "from-T1w_to-template_mode-image_xfm", + "Composite (affine + warp field) transform from T1w native space to T1w-template space.", + ), + ( + "from-template_to-T1w_mode-image_xfm", + "Composite (affine + warp field) transform from T1w-template space to T1w native space.", + ), + ( + "from-longitudinal_to-template_mode-image_desc-linear_xfm", + "Linear (affine) transform from longitudinal-template space to T1w-template space.", + ), + ( + "from-template_to-longitudinal_mode-image_desc-linear_xfm", + "Linear (affine) transform from T1w-template space to longitudinal-template space.", + ), + ( + "from-longitudinal_to-template_mode-image_desc-nonlinear_xfm", + "Nonlinear (warp field) transform from longitudinal-template space to T1w-template space.", + ), + ( + "from-template_to-longitudinal_mode-image_desc-nonlinear_xfm", + "Nonlinear (warp field) transform from T1w-template space to longitudinal-template space.", + ), + ( + "from-longitudinal_to-template_mode-image_xfm", + "Composite (affine + warp field) transform from longitudinal-template space to T1w-template space.", + ), + ( + "from-template_to-longitudinal_mode-image_xfm", + "Composite (affine + warp field) transform from T1w-template space to longitudinal-template space.", + ), + ] }, - "from-template_to-longitudinal_mode-image_xfm": { - "Description": "Composite (affine + warp field) transform from " - "T1w-template space to longitudinal-template " - "space.", - "Template": "T1w-template", + **{ + f"from-{src}_to-{dst}_mode-image_desc-{xfm}_xfm": { + "Description": f"{desc} transform from {src.replace('longitudinal', 'longitudinal-template') if src == 'longitudinal' else src} native space to {dst.replace('longitudinal', 'longitudinal-template') if dst == 'longitudinal' else dst}-template space.", + "Template": "T1w-template", + } + for src in ["T1w", "longitudinal"] + for dst in ["template", "longitudinal"] + for xfm, desc in [ + ("initial", "Initial"), + ("rigid", "Rigid"), + ("affine", "Affine"), + ] + if src != dst }, }, ) @@ -2656,16 +2689,15 @@ def register_ANTs_anat_to_template(wf, cfg, strat_pool, pipe_num, opt=None): wf.connect(node, out, ants_rc, "inputspec.lesion_mask") if "space-longitudinal" in brain: - for key in outputs: + for key in list(outputs.keys()): for direction in ["from", "to"]: if f"{direction}-T1w" in key: new_key = key.replace( f"{direction}-T1w", f"{direction}-longitudinal" ) outputs[new_key] = outputs[key] - del outputs[key] - return (wf, outputs) + return wf, outputs @nodeblock( @@ -2690,44 +2722,37 @@ def register_ANTs_anat_to_template(wf, cfg, strat_pool, pipe_num, opt=None): "label-lesion_mask", ], outputs={ - "space-symtemplate_desc-preproc_T1w": { - "Template": "T1w-brain-template-symmetric" - }, - "from-T1w_to-symtemplate_mode-image_desc-linear_xfm": { - "Template": "T1w-template-symmetric" - }, - "from-symtemplate_to-T1w_mode-image_desc-linear_xfm": { - "Template": "T1w-template-symmetric" - }, - "from-T1w_to-symtemplate_mode-image_desc-nonlinear_xfm": { - "Template": "T1w-template-symmetric" - }, - "from-symtemplate_to-T1w_mode-image_desc-nonlinear_xfm": { - "Template": "T1w-template-symmetric" - }, - "from-T1w_to-symtemplate_mode-image_xfm": { - "Template": "T1w-template-symmetric" - }, - "from-symtemplate_to-T1w_mode-image_xfm": { - "Template": "T1w-template-symmetric" - }, - "from-longitudinal_to-symtemplate_mode-image_desc-linear_xfm": { - "Template": "T1w-template-symmetric" - }, - "from-symtemplate_to-longitudinal_mode-image_desc-linear_xfm": { - "Template": "T1w-template-symmetric" - }, - "from-longitudinal_to-symtemplate_mode-image_desc-nonlinear_xfm": { - "Template": "T1w-template-symmetric" - }, - "from-symtemplate_to-longitudinal_mode-image_desc-nonlinear_xfm": { - "Template": "T1w-template-symmetric" - }, - "from-longitudinal_to-symtemplate_mode-image_xfm": { - "Template": "T1w-template-symmetric" + **{ + k: {"Template": "T1w-template-symmetric"} + for k in [ + "space-symtemplate_desc-preproc_T1w", + "from-T1w_to-symtemplate_mode-image_desc-linear_xfm", + "from-symtemplate_to-T1w_mode-image_desc-linear_xfm", + "from-T1w_to-symtemplate_mode-image_desc-nonlinear_xfm", + "from-symtemplate_to-T1w_mode-image_desc-nonlinear_xfm", + "from-T1w_to-symtemplate_mode-image_xfm", + "from-symtemplate_to-T1w_mode-image_xfm", + "from-longitudinal_to-symtemplate_mode-image_desc-linear_xfm", + "from-symtemplate_to-longitudinal_mode-image_desc-linear_xfm", + "from-longitudinal_to-symtemplate_mode-image_desc-nonlinear_xfm", + "from-symtemplate_to-longitudinal_mode-image_desc-nonlinear_xfm", + "from-longitudinal_to-symtemplate_mode-image_xfm", + "from-symtemplate_to-longitudinal_mode-image_xfm", + ] }, - "from-symtemplate_to-longitudinal_mode-image_xfm": { - "Template": "T1w-template-symmetric" + **{ + f"from-{src}_to-{dst}_mode-image_desc-{xfm}_xfm": { + "Description": f"{desc} transform from {src.replace('longitudinal', 'longitudinal-template') if src == 'longitudinal' else src} native space to {dst.replace('longitudinal', 'longitudinal-template') if dst == 'longitudinal' else dst}-template-symmetric space.", + "Template": "T1w-template-symmetric", + } + for src in ["T1w", "longitudinal"] + for dst in ["symtemplate"] + for xfm, desc in [ + ("initial", "Initial"), + ("rigid", "Rigid"), + ("affine", "Affine"), + ] + if src != dst }, }, ) @@ -2779,15 +2804,13 @@ def register_symmetric_ANTs_anat_to_template(wf, cfg, strat_pool, pipe_num, opt= wf.connect(node, out, ants, "inputspec.lesion_mask") if "space-longitudinal" in brain: - for key in outputs.keys(): + for key in list(outputs.keys()): if "from-T1w" in key: new_key = key.replace("from-T1w", "from-longitudinal") outputs[new_key] = outputs[key] - del outputs[key] if "to-T1w" in key: new_key = key.replace("to-T1w", "to-longitudinal") outputs[new_key] = outputs[key] - del outputs[key] return (wf, outputs) @@ -2804,21 +2827,32 @@ def register_symmetric_ANTs_anat_to_template(wf, cfg, strat_pool, pipe_num, opt= "EPI-template-mask", ], outputs={ - "space-template_desc-preproc_bold": {"Template": "EPI-template"}, - "from-bold_to-EPItemplate_mode-image_desc-linear_xfm": { - "Template": "EPI-template" - }, - "from-EPItemplate_to-bold_mode-image_desc-linear_xfm": { - "Template": "EPI-template" - }, - "from-bold_to-EPItemplate_mode-image_desc-nonlinear_xfm": { - "Template": "EPI-template" + **{ + k: {"Template": "EPI-template"} + for k in [ + "space-template_desc-preproc_bold", + "from-bold_to-EPItemplate_mode-image_desc-linear_xfm", + "from-EPItemplate_to-bold_mode-image_desc-linear_xfm", + "from-bold_to-EPItemplate_mode-image_desc-nonlinear_xfm", + "from-EPItemplate_to-bold_mode-image_desc-nonlinear_xfm", + "from-bold_to-EPItemplate_mode-image_xfm", + "from-EPItemplate_to-bold_mode-image_xfm", + ] }, - "from-EPItemplate_to-bold_mode-image_desc-nonlinear_xfm": { - "Template": "EPI-template" + **{ + f"from-{src}_to-{dst}_mode-image_desc-{xfm}_xfm": { + "Description": f"{desc} transform from {src} native space to {dst} template space.", + "Template": "EPI-template", + } + for src in ["bold", "EPItemplate"] + for dst in ["EPItemplate", "bold"] + for xfm, desc in [ + ("initial", "Initial"), + ("rigid", "Rigid"), + ("affine", "Affine"), + ] + if src != dst }, - "from-bold_to-EPItemplate_mode-image_xfm": {"Template": "EPI-template"}, - "from-EPItemplate_to-bold_mode-image_xfm": {"Template": "EPI-template"}, }, ) def register_ANTs_EPI_to_template(wf, cfg, strat_pool, pipe_num, opt=None): @@ -2882,11 +2916,14 @@ def register_ANTs_EPI_to_template(wf, cfg, strat_pool, pipe_num, opt=None): inputs=[ ( "desc-restore-brain_T1w", + "desc-head_T1w", ["desc-preproc_T1w", "space-longitudinal_desc-brain_T1w"], ["desc-restore_T1w", "desc-preproc_T1w", "desc-reorient_T1w", "T1w"], ["desc-preproc_T1w", "desc-reorient_T1w", "T1w"], "space-T1w_desc-brain_mask", "T1w-template", + "T1w-brain-template", + "T1w-brain-template-mask", "from-T1w_to-template_mode-image_xfm", "from-template_to-T1w_mode-image_xfm", "space-template_desc-brain_T1w", @@ -2896,16 +2933,13 @@ def register_ANTs_EPI_to_template(wf, cfg, strat_pool, pipe_num, opt=None): outputs={ "space-template_desc-preproc_T1w": {"Template": "T1w-template"}, "space-template_desc-head_T1w": {"Template": "T1w-template"}, - "space-template_desc-T1w_mask": {"Template": "T1w-template"}, "from-T1w_to-template_mode-image_xfm": {"Template": "T1w-template"}, "from-template_to-T1w_mode-image_xfm": {"Template": "T1w-template"}, }, ) def overwrite_transform_anat_to_template(wf, cfg, strat_pool, pipe_num, opt=None): """Overwrite ANTs transforms with FSL transforms.""" - xfm_prov = strat_pool.get_cpac_provenance("from-T1w_to-template_mode-image_xfm") - - reg_tool = check_prov_for_regtool(xfm_prov) + reg_tool = strat_pool.reg_tool("from-T1w_to-template_mode-image_xfm") if opt.lower() == "fsl" and reg_tool.lower() == "ants": # Apply head-to-head transforms on brain using ABCD-style registration @@ -3047,6 +3081,14 @@ def overwrite_transform_anat_to_template(wf, cfg, strat_pool, pipe_num, opt=None wf.connect(merge_inv_xfms_to_list, "out", merge_inv_xfms, "in_files") + # Match FOVs using flirt -in infile -ref MNI152_T1_1mm_resample.nii.gz -out my_T1w_resampled.nii.gz -applyxfm -usesqform + match_fovs_T1w = pe.Node( + interface=fsl.FLIRT(), name=f"match_fovs_T1w_{pipe_num}" + ) + match_fovs_T1w.inputs.apply_xfm = True + match_fovs_T1w.inputs.uses_qform = True + match_fovs_T1w.inputs.out_matrix_file = "match_fov.mat" + # applywarp --rel --interp=spline -i ${T1wRestore} -r ${Reference} -w ${OutputTransform} -o ${OutputT1wImageRestore} fsl_apply_warp_t1_to_template = pe.Node( interface=fsl.ApplyWarp(), name=f"FSL-ABCD_T1_to_template_{pipe_num}" @@ -3054,16 +3096,39 @@ def overwrite_transform_anat_to_template(wf, cfg, strat_pool, pipe_num, opt=None fsl_apply_warp_t1_to_template.inputs.relwarp = True fsl_apply_warp_t1_to_template.inputs.interp = "spline" - node, out = strat_pool.get_data(["desc-restore_T1w", "desc-preproc_T1w"]) - wf.connect(node, out, fsl_apply_warp_t1_to_template, "in_file") - node, out = strat_pool.get_data("T1w-template") + wf.connect(node, out, match_fovs_T1w, "reference") wf.connect(node, out, fsl_apply_warp_t1_to_template, "ref_file") + node, out = strat_pool.get_data(["desc-restore_T1w", "desc-head_T1w"]) + wf.connect(node, out, match_fovs_T1w, "in_file") + wf.connect(match_fovs_T1w, "out_file", fsl_apply_warp_t1_to_template, "in_file") + wf.connect( merge_xfms, "merged_file", fsl_apply_warp_t1_to_template, "field_file" ) + concat_match_fov = pe.Node( + interface=fsl.ConvertWarp(), name=f"concat_match_fov_{pipe_num}" + ) + concat_match_fov.inputs.relwarp = True + + wf.connect(match_fovs_T1w, "out_matrix_file", concat_match_fov, "premat") + wf.connect(merge_xfms, "merged_file", concat_match_fov, "warp1") + node, out = strat_pool.get_data("T1w-template") + wf.connect(node, out, concat_match_fov, "reference") + + # Node to concatenate the inverse warp with the FOV matrix + concat_match_fov_inv = pe.Node( + interface=fsl.ConvertWarp(), name=f"concat_match_fov_inv_{pipe_num}" + ) + concat_match_fov_inv.inputs.relwarp = True + + wf.connect(merge_inv_xfms, "merged_file", concat_match_fov_inv, "warp1") + wf.connect(match_fovs_T1w, "out_matrix_file", concat_match_fov_inv, "premat") + node, out = strat_pool.get_data(["desc-restore_T1w", "desc-head_T1w"]) + wf.connect(node, out, concat_match_fov_inv, "reference") + # applywarp --rel --interp=nn -i ${T1wRestoreBrain} -r ${Reference} -w ${OutputTransform} -o ${OutputT1wImageRestoreBrain} fsl_apply_warp_t1_brain_to_template = pe.Node( interface=fsl.ApplyWarp(), name=f"FSL-ABCD_T1_brain_to_template_{pipe_num}" @@ -3072,14 +3137,18 @@ def overwrite_transform_anat_to_template(wf, cfg, strat_pool, pipe_num, opt=None fsl_apply_warp_t1_brain_to_template.inputs.interp = "nn" # TODO connect T1wRestoreBrain, check T1wRestoreBrain quality - node, out = strat_pool.get_data("desc-preproc_T1w") + node, out = strat_pool.get_data(["desc-restore-brain_T1w", "desc-preproc_T1w"]) + wf.connect(node, out, fsl_apply_warp_t1_brain_to_template, "in_file") - node, out = strat_pool.get_data("T1w-template") + node, out = strat_pool.get_data("T1w-brain-template") wf.connect(node, out, fsl_apply_warp_t1_brain_to_template, "ref_file") wf.connect( - merge_xfms, "merged_file", fsl_apply_warp_t1_brain_to_template, "field_file" + concat_match_fov, + "out_file", + fsl_apply_warp_t1_brain_to_template, + "field_file", ) fsl_apply_warp_t1_brain_mask_to_template = pe.Node( @@ -3090,14 +3159,15 @@ def overwrite_transform_anat_to_template(wf, cfg, strat_pool, pipe_num, opt=None fsl_apply_warp_t1_brain_mask_to_template.inputs.interp = "nn" node, out = strat_pool.get_data("space-T1w_desc-brain_mask") + wf.connect(node, out, fsl_apply_warp_t1_brain_mask_to_template, "in_file") - node, out = strat_pool.get_data("T1w-template") + node, out = strat_pool.get_data("T1w-brain-template-mask") wf.connect(node, out, fsl_apply_warp_t1_brain_mask_to_template, "ref_file") wf.connect( - merge_xfms, - "merged_file", + concat_match_fov, + "out_file", fsl_apply_warp_t1_brain_mask_to_template, "field_file", ) @@ -3116,14 +3186,46 @@ def overwrite_transform_anat_to_template(wf, cfg, strat_pool, pipe_num, opt=None outputs = { "space-template_desc-preproc_T1w": (apply_mask, "out_file"), "space-template_desc-head_T1w": (fsl_apply_warp_t1_to_template, "out_file"), - "space-template_desc-T1w_mask": ( - fsl_apply_warp_t1_brain_mask_to_template, - "out_file", - ), - "from-T1w_to-template_mode-image_xfm": (merge_xfms, "merged_file"), - "from-template_to-T1w_mode-image_xfm": (merge_inv_xfms, "merged_file"), + "from-T1w_to-template_mode-image_xfm": (concat_match_fov, "out_file"), + "from-template_to-T1w_mode-image_xfm": (concat_match_fov_inv, "out_file"), } + else: + outputs = {} + + return (wf, outputs) + + +@nodeblock( + name="mask_sbref", + switch=[ + ["registration_workflows", "functional_registration", "coregistration", "run"], + [ + "registration_workflows", + "functional_registration", + "coregistration", + "func_input_prep", + "mask_sbref", + ], + ], + inputs=[("sbref", "space-bold_desc-brain_mask")], + outputs=["sbref"], +) +def mask_sbref(wf, cfg, strat_pool, pipe_num, opt=None): + """Mask sbref with brain mask.""" + mask_sbref = pe.Node(interface=afni.Calc(), name=f"mask_sbref_{pipe_num}") + + mask_sbref.inputs.expr = "a*b" + mask_sbref.inputs.outputtype = "NIFTI_GZ" + + node, out = strat_pool.get_data("sbref") + wf.connect(node, out, mask_sbref, "in_file_a") + + node, out = strat_pool.get_data("space-bold_desc-brain_mask") + wf.connect(node, out, mask_sbref, "in_file_b") + + outputs = {"sbref": (mask_sbref, "out_file")} + return (wf, outputs) @@ -3138,7 +3240,7 @@ def overwrite_transform_anat_to_template(wf, cfg, strat_pool, pipe_num, opt=None "input", ], option_val="Selected_Functional_Volume", - inputs=[("desc-brain_bold", ["desc-motion_bold", "bold"], "sbref")], + inputs=[("desc-preproc_bold", "sbref")], outputs=["sbref"], ) def coregistration_prep_vol(wf, cfg, strat_pool, pipe_num, opt=None): @@ -3153,15 +3255,7 @@ def coregistration_prep_vol(wf, cfg, strat_pool, pipe_num, opt=None): outputtype="NIFTI_GZ", ) - if not cfg.registration_workflows["functional_registration"]["coregistration"][ - "func_input_prep" - ]["reg_with_skull"]: - node, out = strat_pool.get_data("desc-brain_bold") - else: - # TODO check which file is functional_skull_leaf - # TODO add a function to choose brain or skull? - node, out = strat_pool.get_data(["desc-motion_bold", "bold"]) - + node, out = strat_pool.get_data("desc-preproc_bold") wf.connect(node, out, get_func_volume, "in_file_a") coreg_input = (get_func_volume, "out_file") @@ -3223,14 +3317,34 @@ def coregistration_prep_mean(wf, cfg, strat_pool, pipe_num, opt=None): "input", ], option_val="fmriprep_reference", - inputs=["desc-ref_bold"], + inputs=[ + ("motion-basefile", "desc-preproc_bold"), + "FSL-AFNI-bold-ref", + "FSL-AFNI-brain-mask", + "FSL-AFNI-brain-probseg", + "desc-unifized_bold", + ], outputs=["sbref"], ) def coregistration_prep_fmriprep(wf, cfg, strat_pool, pipe_num, opt=None): """Generate fMRIPrep-style single-band reference for coregistration.""" - coreg_input = strat_pool.get_data("desc-ref_bold") + outputs = {} - outputs = {"sbref": coreg_input} + if not strat_pool.check_rpool("desc-unifized_bold"): + fsl_afni_wf = fsl_afni_subworkflow(cfg, pipe_num, opt) + + for key in [ + "FSL-AFNI-bold-ref", + "FSL-AFNI-brain-mask", + "FSL-AFNI-brain-probseg", + "motion-basefile", + ]: + node, out = strat_pool.get_data(key) + wf.connect(node, out, fsl_afni_wf, f"inputspec.{key}") + + outputs["sbref"] = (fsl_afni_wf, "outputspec.desc-unifized_bold") + else: + outputs["sbref"] = strat_pool.get_data("desc-unifized_bold") return (wf, outputs) @@ -3251,7 +3365,9 @@ def coregistration_prep_fmriprep(wf, cfg, strat_pool, pipe_num, opt=None): ), ( "desc-preproc_T1w", + "space-T1w_desc-brain_mask", "desc-restore-brain_T1w", + ["desc-restore_T1w", "desc-head_T1w"], "desc-preproc_T2w", "desc-preproc_T2w", "T2w", @@ -3322,21 +3438,7 @@ def coregistration(wf, cfg, strat_pool, pipe_num, opt=None): node, out = strat_pool.get_data("sbref") wf.connect(node, out, func_to_anat, "inputspec.func") - if ( - cfg.registration_workflows["functional_registration"]["coregistration"][ - "reference" - ] - == "brain" - ): - # TODO: use JSON meta-data to confirm - node, out = strat_pool.get_data("desc-preproc_T1w") - elif ( - cfg.registration_workflows["functional_registration"]["coregistration"][ - "reference" - ] - == "restore-brain" - ): - node, out = strat_pool.get_data("desc-restore-brain_T1w") + node, out = strat_pool.get_data(["desc-restore-brain_T1w", "desc-preproc_T1w"]) wf.connect(node, out, func_to_anat, "inputspec.anat") if diff_complete: @@ -3502,8 +3604,7 @@ def create_func_to_T1template_xfm(wf, cfg, strat_pool, pipe_num, opt=None): Condense the BOLD-to-T1 coregistration transform and the T1-to-template transform into one transform matrix. """ - xfm_prov = strat_pool.get_cpac_provenance("from-T1w_to-template_mode-image_xfm") - reg_tool = check_prov_for_regtool(xfm_prov) + reg_tool = strat_pool.reg_tool("from-T1w_to-template_mode-image_xfm") xfm, outputs = bold_to_T1template_xfm_connector( f"create_func_to_T1wtemplate_xfm_{pipe_num}", cfg, reg_tool, symmetric=False @@ -3581,8 +3682,7 @@ def create_func_to_T1template_symmetric_xfm(wf, cfg, strat_pool, pipe_num, opt=N Condense the BOLD-to-T1 coregistration transform and the T1-to-symmetric-template transform into one transform matrix. """ - xfm_prov = strat_pool.get_cpac_provenance("from-T1w_to-symtemplate_mode-image_xfm") - reg_tool = check_prov_for_regtool(xfm_prov) + reg_tool = strat_pool.reg_tool("from-T1w_to-symtemplate_mode-image_xfm") xfm, outputs = bold_to_T1template_xfm_connector( f"create_func_to_T1wsymtemplate_xfm_{pipe_num}", @@ -3638,7 +3738,7 @@ def create_func_to_T1template_symmetric_xfm(wf, cfg, strat_pool, pipe_num, opt=N "sbref", "desc-preproc_bold", "desc-stc_bold", - "bold", + "desc-reorient_bold", "from-bold_to-T1w_mode-image_desc-linear_xfm", ), "despiked-fieldmap", @@ -3726,7 +3826,7 @@ def apply_phasediff_to_timeseries_separately(wf, cfg, strat_pool, pipe_num, opt= node, out = strat_pool.get_data("desc-stc_bold") out_label = "desc-stc_bold" elif opt == "abcd": - node, out = strat_pool.get_data("bold") + node, out = strat_pool.get_data("desc-reorient_bold") out_label = "bold" wf.connect(node, out, warp_bold, "in_file") @@ -3777,18 +3877,17 @@ def apply_phasediff_to_timeseries_separately(wf, cfg, strat_pool, pipe_num, opt= "sbref", "desc-preproc_bold", "desc-stc_bold", - "bold", + "desc-reorient_bold", "from-bold_to-template_mode-image_xfm", "ants-blip-warp", "fsl-blip-warp", ) ], - outputs=["desc-preproc_bold", "desc-stc_bold", "bold"], + outputs=["desc-preproc_bold", "desc-stc_bold", "desc-reorient_bold"], ) def apply_blip_to_timeseries_separately(wf, cfg, strat_pool, pipe_num, opt=None): """Apply blip to timeseries.""" - xfm_prov = strat_pool.get_cpac_provenance("from-bold_to-template_mode-image_xfm") - reg_tool = check_prov_for_regtool(xfm_prov) + reg_tool = strat_pool.reg_tool("from-bold_to-template_mode-image_xfm") outputs = {"desc-preproc_bold": strat_pool.get_data("desc-preproc_bold")} if strat_pool.check_rpool("ants-blip-warp"): @@ -3834,8 +3933,8 @@ def apply_blip_to_timeseries_separately(wf, cfg, strat_pool, pipe_num, opt=None) node, out = strat_pool.get_data("desc-stc_bold") out_label = "desc-stc_bold" elif opt == "abcd": - node, out = strat_pool.get_data("bold") - out_label = "bold" + node, out = strat_pool.get_data("desc-reorient_bold") + out_label = "desc-reorient_bold" wf.connect(node, out, apply_xfm, "inputspec.input_image") @@ -3865,8 +3964,7 @@ def apply_blip_to_timeseries_separately(wf, cfg, strat_pool, pipe_num, opt=None) ) def warp_wholeheadT1_to_template(wf, cfg, strat_pool, pipe_num, opt=None): """Warp T1 head to template.""" - xfm_prov = strat_pool.get_cpac_provenance("from-T1w_to-template_mode-image_xfm") - reg_tool = check_prov_for_regtool(xfm_prov) + reg_tool = strat_pool.reg_tool("from-T1w_to-template_mode-image_xfm") num_cpus = cfg.pipeline_setup["system_config"]["max_cores_per_participant"] @@ -3919,8 +4017,16 @@ def warp_wholeheadT1_to_template(wf, cfg, strat_pool, pipe_num, opt=None): ) def warp_T1mask_to_template(wf, cfg, strat_pool, pipe_num, opt=None): """Warp T1 mask to template.""" - xfm_prov = strat_pool.get_cpac_provenance("from-T1w_to-template_mode-image_xfm") - reg_tool = check_prov_for_regtool(xfm_prov) + if ( + cfg.registration_workflows["anatomical_registration"]["overwrite_transform"] + and cfg.registration_workflows["anatomical_registration"][ + "overwrite_transform" + ]["using"] + == "FSL" + ): + reg_tool = "fsl" + else: + reg_tool = strat_pool.reg_tool("from-T1w_to-template_mode-image_xfm") num_cpus = cfg.pipeline_setup["system_config"]["max_cores_per_participant"] @@ -3980,8 +4086,7 @@ def warp_T1mask_to_template(wf, cfg, strat_pool, pipe_num, opt=None): ) def warp_timeseries_to_T1template(wf, cfg, strat_pool, pipe_num, opt=None): """Warp timeseries to T1 template.""" - xfm_prov = strat_pool.get_cpac_provenance("from-bold_to-template_mode-image_xfm") - reg_tool = check_prov_for_regtool(xfm_prov) + reg_tool = strat_pool.reg_tool("from-bold_to-template_mode-image_xfm") num_cpus = cfg.pipeline_setup["system_config"]["max_cores_per_participant"] @@ -4043,8 +4148,7 @@ def warp_timeseries_to_T1template(wf, cfg, strat_pool, pipe_num, opt=None): ) def warp_timeseries_to_T1template_deriv(wf, cfg, strat_pool, pipe_num, opt=None): """Warp timeseries to T1 template at derivative resolution.""" - xfm_prov = strat_pool.get_cpac_provenance("from-bold_to-template_mode-image_xfm") - reg_tool = check_prov_for_regtool(xfm_prov) + reg_tool = strat_pool.reg_tool("from-bold_to-template_mode-image_xfm") num_cpus = cfg.pipeline_setup["system_config"]["max_cores_per_participant"] @@ -4098,15 +4202,24 @@ def warp_timeseries_to_T1template_deriv(wf, cfg, strat_pool, pipe_num, opt=None) option_key=["apply_transform", "using"], option_val="abcd", inputs=[ - ("desc-preproc_bold", "bold", "motion-basefile", "coordinate-transformation"), + ( + "desc-preproc_bold", + "desc-reorient_bold", + "motion-basefile", + "coordinate-transformation", + ), "from-T1w_to-template_mode-image_xfm", "from-bold_to-T1w_mode-image_desc-linear_xfm", "from-bold_to-template_mode-image_xfm", "fsl-blip-warp", "desc-preproc_T1w", - "space-template_res-bold_desc-brain_T1w", + "desc-head_T1w", + "space-template_res-bold_desc-head_T1w", "space-template_desc-bold_mask", "T1w-brain-template-funcreg", + "T1w-template-funcreg", + "space-template_desc-preproc_T1w", + "space-template_desc-brain_mask", ], outputs={ "space-template_desc-preproc_bold": {"Template": "T1w-brain-template-funcreg"}, @@ -4131,18 +4244,15 @@ def warp_timeseries_to_T1template_abcd(wf, cfg, strat_pool, pipe_num, opt=None): convert_func_to_anat_linear_warp.inputs.out_relwarp = True convert_func_to_anat_linear_warp.inputs.relwarp = True - node, out = strat_pool.get_data("desc-preproc_T1w") + node, out = strat_pool.get_data("desc-head_T1w") wf.connect(node, out, convert_func_to_anat_linear_warp, "reference") - if strat_pool.check_rpool("fsl-blip-warp"): - node, out = strat_pool.get_data("from-bold_to-T1w_mode-image_desc-linear_xfm") - wf.connect(node, out, convert_func_to_anat_linear_warp, "postmat") + node, out = strat_pool.get_data("from-bold_to-T1w_mode-image_desc-linear_xfm") + wf.connect(node, out, convert_func_to_anat_linear_warp, "premat") + if strat_pool.check_rpool("fsl-blip-warp"): node, out = strat_pool.get_data("fsl-blip-warp") wf.connect(node, out, convert_func_to_anat_linear_warp, "warp1") - else: - node, out = strat_pool.get_data("from-bold_to-T1w_mode-image_desc-linear_xfm") - wf.connect(node, out, convert_func_to_anat_linear_warp, "premat") # https://github.com/DCAN-Labs/DCAN-HCP/blob/1d90814/fMRIVolume/scripts/OneStepResampling.sh#L140 # convertwarp --relout --rel --warp1=${fMRIToStructuralInput} --warp2=${StructuralToStandard} --ref=${WD}/${T1wImageFile}.${FinalfMRIResolution} --out=${OutputTransform} @@ -4163,8 +4273,13 @@ def warp_timeseries_to_T1template_abcd(wf, cfg, strat_pool, pipe_num, opt=None): node, out = strat_pool.get_data("from-T1w_to-template_mode-image_xfm") wf.connect(node, out, convert_func_to_standard_warp, "warp2") - node, out = strat_pool.get_data("space-template_res-bold_desc-brain_T1w") - wf.connect(node, out, convert_func_to_standard_warp, "reference") + node, out = strat_pool.get_data("space-template_res-bold_desc-head_T1w") + wf.connect( + node, + out, + convert_func_to_standard_warp, + "reference", + ) # TODO add condition: if no gradient distortion # https://github.com/DCAN-Labs/DCAN-HCP/blob/6466b78/fMRIVolume/GenericfMRIVolumeProcessingPipeline.sh#L283-L284 @@ -4176,25 +4291,16 @@ def warp_timeseries_to_T1template_abcd(wf, cfg, strat_pool, pipe_num, opt=None): extract_func_roi.inputs.t_min = 0 extract_func_roi.inputs.t_size = 3 - node, out = strat_pool.get_data("bold") + node, out = strat_pool.get_data("desc-reorient_bold") wf.connect(node, out, extract_func_roi, "in_file") - # fslmaths "$fMRIFolder"/"$NameOffMRI"_gdc_warp -mul 0 "$fMRIFolder"/"$NameOffMRI"_gdc_warp - multiply_func_roi_by_zero = pe.Node( - interface=fsl.maths.MathsCommand(), name=f"multiply_func_roi_by_zero_{pipe_num}" - ) - - multiply_func_roi_by_zero.inputs.args = "-mul 0" - - wf.connect(extract_func_roi, "roi_file", multiply_func_roi_by_zero, "in_file") - # https://github.com/DCAN-Labs/DCAN-HCP/blob/1d90814/fMRIVolume/scripts/OneStepResampling.sh#L168-L193 # fslsplit ${InputfMRI} ${WD}/prevols/vol -t split_func = pe.Node(interface=fsl.Split(), name=f"split_func_{pipe_num}") split_func.inputs.dimension = "t" - node, out = strat_pool.get_data("bold") + node, out = strat_pool.get_data("desc-reorient_bold") wf.connect(node, out, split_func, "in_file") ### Loop starts! ### @@ -4208,15 +4314,15 @@ def warp_timeseries_to_T1template_abcd(wf, cfg, strat_pool, pipe_num, opt=None): convert_motion_distortion_warp.inputs.out_relwarp = True convert_motion_distortion_warp.inputs.relwarp = True - wf.connect( - multiply_func_roi_by_zero, "out_file", convert_motion_distortion_warp, "warp1" - ) - wf.connect(split_func, "out_files", convert_motion_distortion_warp, "reference") node, out = strat_pool.get_data("coordinate-transformation") wf.connect(node, out, convert_motion_distortion_warp, "postmat") + if strat_pool.check_rpool("gradient-distortion-field"): + node, out = strat_pool.get_data("gradient-distortion-field") + wf.connect(node, out, convert_motion_distortion_warp, "warp1") + # convertwarp --relout --rel --ref=${WD}/${T1wImageFile}.${FinalfMRIResolution} --warp1=${MotionMatrixFolder}/${MotionMatrixPrefix}${vnum}_gdc_warp.nii.gz --warp2=${OutputTransform} --out=${MotionMatrixFolder}/${MotionMatrixPrefix}${vnum}_all_warp.nii.gz convert_registration_warp = pe.MapNode( interface=fsl.ConvertWarp(), @@ -4227,8 +4333,13 @@ def warp_timeseries_to_T1template_abcd(wf, cfg, strat_pool, pipe_num, opt=None): convert_registration_warp.inputs.out_relwarp = True convert_registration_warp.inputs.relwarp = True - node, out = strat_pool.get_data("space-template_res-bold_desc-brain_T1w") - wf.connect(node, out, convert_registration_warp, "reference") + node, out = strat_pool.get_data("space-template_res-bold_desc-head_T1w") + wf.connect( + node, + out, + convert_registration_warp, + "reference", + ) wf.connect( convert_motion_distortion_warp, "out_file", convert_registration_warp, "warp1" @@ -4238,17 +4349,6 @@ def warp_timeseries_to_T1template_abcd(wf, cfg, strat_pool, pipe_num, opt=None): convert_func_to_standard_warp, "out_file", convert_registration_warp, "warp2" ) - # fslmaths ${WD}/prevols/vol${vnum}.nii.gz -mul 0 -add 1 ${WD}/prevols/vol${vnum}_mask.nii.gz - generate_vol_mask = pe.MapNode( - interface=fsl.maths.MathsCommand(), - name=f"generate_mask_{pipe_num}", - iterfield=["in_file"], - ) - - generate_vol_mask.inputs.args = "-mul 0 -add 1" - - wf.connect(split_func, "out_files", generate_vol_mask, "in_file") - # applywarp --rel --interp=spline --in=${WD}/prevols/vol${vnum}.nii.gz --warp=${MotionMatrixFolder}/${MotionMatrixPrefix}${vnum}_all_warp.nii.gz --ref=${WD}/${T1wImageFile}.${FinalfMRIResolution} --out=${WD}/postvols/vol${vnum}.nii.gz applywarp_func_to_standard = pe.MapNode( interface=fsl.ApplyWarp(), @@ -4265,33 +4365,14 @@ def warp_timeseries_to_T1template_abcd(wf, cfg, strat_pool, pipe_num, opt=None): convert_registration_warp, "out_file", applywarp_func_to_standard, "field_file" ) - node, out = strat_pool.get_data("space-template_res-bold_desc-brain_T1w") - wf.connect(node, out, applywarp_func_to_standard, "ref_file") - - # applywarp --rel --interp=nn --in=${WD}/prevols/vol${vnum}_mask.nii.gz --warp=${MotionMatrixFolder}/${MotionMatrixPrefix}${vnum}_all_warp.nii.gz --ref=${WD}/${T1wImageFile}.${FinalfMRIResolution} --out=${WD}/postvols/vol${vnum}_mask.nii.gz - applywarp_func_mask_to_standard = pe.MapNode( - interface=fsl.ApplyWarp(), - name=f"applywarp_func_mask_to_standard_{pipe_num}", - iterfield=["in_file", "field_file"], - ) - - applywarp_func_mask_to_standard.inputs.relwarp = True - applywarp_func_mask_to_standard.inputs.interp = "nn" - + node, out = strat_pool.get_data("space-template_res-bold_desc-head_T1w") wf.connect( - generate_vol_mask, "out_file", applywarp_func_mask_to_standard, "in_file" + node, + out, + applywarp_func_to_standard, + "ref_file", ) - wf.connect( - convert_registration_warp, - "out_file", - applywarp_func_mask_to_standard, - "field_file", - ) - - node, out = strat_pool.get_data("space-template_res-bold_desc-brain_T1w") - wf.connect(node, out, applywarp_func_mask_to_standard, "ref_file") - ### Loop ends! ### # fslmerge -tr ${OutputfMRI} $FrameMergeSTRING $TR_vol @@ -4305,45 +4386,6 @@ def warp_timeseries_to_T1template_abcd(wf, cfg, strat_pool, pipe_num, opt=None): applywarp_func_to_standard, "out_file", merge_func_to_standard, "in_files" ) - # fslmerge -tr ${OutputfMRI}_mask $FrameMergeSTRINGII $TR_vol - merge_func_mask_to_standard = pe.Node( - interface=fslMerge(), name=f"merge_func_mask_to_standard_{pipe_num}" - ) - - merge_func_mask_to_standard.inputs.dimension = "t" - - wf.connect( - applywarp_func_mask_to_standard, - "out_file", - merge_func_mask_to_standard, - "in_files", - ) - - # fslmaths ${OutputfMRI}_mask -Tmin ${OutputfMRI}_mask - find_min_mask = pe.Node( - interface=fsl.maths.MathsCommand(), name=f"find_min_mask_{pipe_num}" - ) - - find_min_mask.inputs.args = "-Tmin" - - wf.connect(merge_func_mask_to_standard, "merged_file", find_min_mask, "in_file") - - # Combine transformations: gradient non-linearity distortion + fMRI_dc to standard - # convertwarp --relout --rel --ref=${WD}/${T1wImageFile}.${FinalfMRIResolution} --warp1=${GradientDistortionField} --warp2=${OutputTransform} --out=${WD}/Scout_gdc_MNI_warp.nii.gz - convert_dc_warp = pe.Node( - interface=fsl.ConvertWarp(), name=f"convert_dc_warp_{pipe_num}" - ) - - convert_dc_warp.inputs.out_relwarp = True - convert_dc_warp.inputs.relwarp = True - - node, out = strat_pool.get_data("space-template_res-bold_desc-brain_T1w") - wf.connect(node, out, convert_dc_warp, "reference") - - wf.connect(multiply_func_roi_by_zero, "out_file", convert_dc_warp, "warp1") - - wf.connect(convert_func_to_standard_warp, "out_file", convert_dc_warp, "warp2") - # applywarp --rel --interp=spline --in=${ScoutInput} -w ${WD}/Scout_gdc_MNI_warp.nii.gz -r ${WD}/${T1wImageFile}.${FinalfMRIResolution} -o ${ScoutOutput} applywarp_scout = pe.Node( interface=fsl.ApplyWarp(), name=f"applywarp_scout_input_{pipe_num}" @@ -4355,46 +4397,20 @@ def warp_timeseries_to_T1template_abcd(wf, cfg, strat_pool, pipe_num, opt=None): node, out = strat_pool.get_data("motion-basefile") wf.connect(node, out, applywarp_scout, "in_file") - node, out = strat_pool.get_data("space-template_res-bold_desc-brain_T1w") - wf.connect(node, out, applywarp_scout, "ref_file") - - wf.connect(convert_dc_warp, "out_file", applywarp_scout, "field_file") - - # https://github.com/DCAN-Labs/DCAN-HCP/blob/1214767/fMRIVolume/scripts/IntensityNormalization.sh#L124-L127 - # fslmaths ${InputfMRI} -mas ${BrainMask} -mas ${InputfMRI}_mask -thr 0 -ing 10000 ${OutputfMRI} -odt float - merge_func_mask = pe.Node(util.Merge(2), name=f"merge_func_mask_{pipe_num}") - - node, out = strat_pool.get_data("space-template_desc-bold_mask") - wf.connect(node, out, merge_func_mask, "in1") - - wf.connect(find_min_mask, "out_file", merge_func_mask, "in2") - - extract_func_brain = pe.Node( - interface=fsl.MultiImageMaths(), name=f"extract_func_brain_{pipe_num}" - ) - - extract_func_brain.inputs.op_string = "-mas %s -mas %s -thr 0 -ing 10000" - extract_func_brain.inputs.output_datatype = "float" - - wf.connect(merge_func_to_standard, "merged_file", extract_func_brain, "in_file") - - wf.connect(merge_func_mask, "out", extract_func_brain, "operand_files") - - # fslmaths ${ScoutInput} -mas ${BrainMask} -mas ${InputfMRI}_mask -thr 0 -ing 10000 ${ScoutOutput} -odt float - extract_scout_brain = pe.Node( - interface=fsl.MultiImageMaths(), name=f"extract_scout_brain_{pipe_num}" + node, out = strat_pool.get_data("space-template_res-bold_desc-head_T1w") + wf.connect( + node, + out, + applywarp_scout, + "ref_file", ) - extract_scout_brain.inputs.op_string = "-mas %s -mas %s -thr 0 -ing 10000" - extract_scout_brain.inputs.output_datatype = "float" - - wf.connect(applywarp_scout, "out_file", extract_scout_brain, "in_file") - - wf.connect(merge_func_mask, "out", extract_scout_brain, "operand_files") + # warp field is just fMRI->standard (skip GDC) + wf.connect(convert_func_to_standard_warp, "out_file", applywarp_scout, "field_file") outputs = { - "space-template_desc-preproc_bold": (extract_func_brain, "out_file"), - "space-template_desc-scout_bold": (extract_scout_brain, "out_file"), + "space-template_desc-preproc_bold": (merge_func_to_standard, "merged_file"), + "space-template_desc-scout_bold": (applywarp_scout, "out_file"), "space-template_desc-head_bold": (merge_func_to_standard, "merged_file"), } @@ -4413,13 +4429,13 @@ def warp_timeseries_to_T1template_abcd(wf, cfg, strat_pool, pipe_num, opt=None): option_val="dcan_nhp", inputs=[ ( - ["desc-reorient_bold", "bold"], + ["desc-reorient_bold", "desc-preproc_bold"], "coordinate-transformation", "from-T1w_to-template_mode-image_warp", "from-bold_to-T1w_mode-image_desc-linear_warp", "T1w-template", "space-template_desc-head_T1w", - "space-template_desc-T1w_mask", + "space-template_desc-brain_mask", "space-template_desc-T1wT2w_biasfield", ) ], @@ -4480,7 +4496,7 @@ def warp_timeseries_to_T1template_dcan_nhp(wf, cfg, strat_pool, pipe_num, opt=No "anatomical_registration" ]["registration"]["FSL-FNIRT"]["identity_matrix"] - node, out = strat_pool.get_data("space-template_desc-T1w_mask") + node, out = strat_pool.get_data("space-template_desc-brain_mask") wf.connect(node, out, applywarp_anat_mask_res, "in_file") wf.connect(applywarp_anat_res, "out_file", applywarp_anat_mask_res, "ref_file") @@ -4544,7 +4560,7 @@ def warp_timeseries_to_T1template_dcan_nhp(wf, cfg, strat_pool, pipe_num, opt=No extract_func_roi.inputs.t_min = 0 extract_func_roi.inputs.t_size = 3 - node, out = strat_pool.get_data(["desc-reorient_bold", "bold"]) + node, out = strat_pool.get_data(["desc-reorient_bold", "desc-preproc_bold"]) wf.connect(node, out, extract_func_roi, "in_file") # fslmaths "$fMRIFolder"/"$NameOffMRI"_gdc_warp -mul 0 "$fMRIFolder"/"$NameOffMRI"_gdc_warp @@ -4562,7 +4578,7 @@ def warp_timeseries_to_T1template_dcan_nhp(wf, cfg, strat_pool, pipe_num, opt=No split_func.inputs.dimension = "t" - node, out = strat_pool.get_data(["desc-reorient_bold", "bold"]) + node, out = strat_pool.get_data(["desc-reorient_bold", "desc-preproc_bold"]) wf.connect(node, out, split_func, "in_file") ### Loop starts! ### @@ -4776,7 +4792,7 @@ def warp_timeseries_to_T1template_dcan_nhp(wf, cfg, strat_pool, pipe_num, opt=No }, ) def single_step_resample_timeseries_to_T1template( - wf, cfg, strat_pool, pipe_num, opt=None + wf, cfg, strat_pool: "ResourcePool", pipe_num, opt=None ): """Apply motion correction, coreg, anat-to-template transforms... @@ -4816,8 +4832,7 @@ def single_step_resample_timeseries_to_T1template( # OF THE POSSIBILITY OF SUCH DAMAGE. # Modifications copyright (C) 2021 - 2024 C-PAC Developers - xfm_prov = strat_pool.get_cpac_provenance("from-T1w_to-template_mode-image_xfm") - reg_tool = check_prov_for_regtool(xfm_prov) + reg_tool = strat_pool.reg_tool("from-T1w_to-template_mode-image_xfm") bbr2itk = pe.Node( Function( @@ -4875,9 +4890,7 @@ def single_step_resample_timeseries_to_T1template( wf.connect(node, out, motionxfm2itk, "source_file") node, out = strat_pool.get_data("coordinate-transformation") - motion_correct_tool = check_prov_for_motion_tool( - strat_pool.get_cpac_provenance("coordinate-transformation") - ) + motion_correct_tool = strat_pool.motion_tool("coordinate-transformation") if motion_correct_tool == "mcflirt": wf.connect(node, out, motionxfm2itk, "transform_file") elif motion_correct_tool == "3dvolreg": @@ -5416,8 +5429,8 @@ def warp_tissuemask_to_template(wf, cfg, strat_pool, pipe_num, xfm, template_spa def warp_resource_to_template( wf: pe.Workflow, - cfg, - strat_pool, + cfg: "Configuration", + strat_pool: "ResourcePool", pipe_num: int, input_resource: list[str] | str, xfm: str, @@ -5465,8 +5478,7 @@ def warp_resource_to_template( if template_space == "": template_space = "T1w" # determine tool used for registration - xfm_prov = strat_pool.get_cpac_provenance(xfm) - reg_tool = check_prov_for_regtool(xfm_prov) + reg_tool = strat_pool.reg_tool(xfm) # set 'resource' if strat_pool.check_rpool(input_resource): resource, input_resource = strat_pool.get_data( diff --git a/CPAC/registration/tests/mocks.py b/CPAC/registration/tests/mocks.py index 18501c5a9a..4f35595abd 100644 --- a/CPAC/registration/tests/mocks.py +++ b/CPAC/registration/tests/mocks.py @@ -151,6 +151,7 @@ def configuration_strategy_mock(method="FSL"): resampled_template.inputs.template = template resampled_template.inputs.template_name = template_name resampled_template.inputs.tag = tag + resampled_template.inputs.orientation = "RPI" strat.update_resource_pool( {template_name: (resampled_template, "resampled_template")} diff --git a/CPAC/registration/tests/test_registration.py b/CPAC/registration/tests/test_registration.py index 58741da445..d8e8228497 100755 --- a/CPAC/registration/tests/test_registration.py +++ b/CPAC/registration/tests/test_registration.py @@ -130,7 +130,7 @@ def test_registration_lesion(): anat_preproc.inputs.inputspec.anat = anat_file - lesion_preproc = create_lesion_preproc(wf_name="lesion_preproc") + lesion_preproc = create_lesion_preproc(cfg, wf_name="lesion_preproc") lesion_preproc.inputs.inputspec.lesion = lesion_file diff --git a/CPAC/registration/tests/test_registration_connectors.py b/CPAC/registration/tests/test_registration_connectors.py new file mode 100644 index 0000000000..9a41f3f908 --- /dev/null +++ b/CPAC/registration/tests/test_registration_connectors.py @@ -0,0 +1,67 @@ +# Copyright (C) 2025 C-PAC Developers + +# This file is part of C-PAC. + +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. + +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . +"""Registration connector tests.""" + +import pytest + +from CPAC.registration.registration import ( + ANTs_registration_connector, + FSL_registration_connector, +) +from CPAC.utils.configuration import Configuration +from CPAC.utils.tests.test_utils import check_expected_keys + +pytestmark = pytest.mark.parametrize("sink_native_transforms", [True, False]) + + +def test_ants_registration_connector(sink_native_transforms: bool) -> None: + """Test ANTs registration connector with various configurations.""" + wf_name = "test_ants_registration_connector" + cfg = Configuration( + { + "pipeline_setup": {"system_config": {"num_ants_threads": 1}}, + "registration_workflows": { + "sink_native_transforms": sink_native_transforms, + "anatomical_registration": { + "reg_with_skull": True, + "registration": {"ANTs": {"use_lesion_mask": False}}, + }, + }, + } + ) + params = {"metric": "MI"} + _, outputs = ANTs_registration_connector(wf_name, cfg=cfg, params=params) + expected_keys = { + "from-T1w_to-template_mode-image_desc-initial_xfm", + "from-T1w_to-template_mode-image_desc-rigid_xfm", + "from-T1w_to-template_mode-image_desc-affine_xfm", + } + check_expected_keys(sink_native_transforms, outputs, expected_keys) + + +def test_fsl_registration_connector(sink_native_transforms: bool) -> None: + """Test FSL registration connector with various configurations.""" + wf_name = "test_fsl_registration_connector" + cfg = Configuration( + {"registration_workflows": {"sink_native_transforms": sink_native_transforms}} + ) + _, outputs = FSL_registration_connector(wf_name, cfg, opt="FSL") + expected_keys = { + "from-T1w_to-template_mode-image_desc-flirt_xfm", + "from-template_to-T1w_mode-image_desc-flirt_xfm", + } + check_expected_keys(sink_native_transforms, outputs, expected_keys) diff --git a/CPAC/resources/__init__.py b/CPAC/resources/__init__.py index e69de29bb2..befaed1aa1 100644 --- a/CPAC/resources/__init__.py +++ b/CPAC/resources/__init__.py @@ -0,0 +1,17 @@ +# Copyright (C) 2013-2025 C-PAC Developers + +# This file is part of C-PAC. + +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. + +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . +"""Resources for C-PAC.""" diff --git a/CPAC/resources/configs/1.7-1.8-nesting-mappings.yml b/CPAC/resources/configs/1.7-1.8-nesting-mappings.yml index dd83685bc1..65bcb95b9c 100644 --- a/CPAC/resources/configs/1.7-1.8-nesting-mappings.yml +++ b/CPAC/resources/configs/1.7-1.8-nesting-mappings.yml @@ -251,11 +251,6 @@ bet_frac: - brain_extraction - FSL-BET - frac -bet_mask_boolean: - - anatomical_preproc - - brain_extraction - - FSL-BET - - mask_boolean bet_mesh_boolean: - anatomical_preproc - brain_extraction diff --git a/CPAC/resources/configs/__init__.py b/CPAC/resources/configs/__init__.py new file mode 100644 index 0000000000..ac7754d63c --- /dev/null +++ b/CPAC/resources/configs/__init__.py @@ -0,0 +1,23 @@ +# Copyright (C) 2017-2025 C-PAC Developers + +# This file is part of C-PAC. + +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. + +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . +"""Configurations for C-PAC.""" + +from importlib.resources import as_file, files + +with as_file(files("CPAC").joinpath("resources/configs")) as _configs: + CONFIGS_PATH = _configs + """Path to pre-built C-PAC configurations.""" diff --git a/CPAC/resources/configs/pipeline_config_abcd-options.yml b/CPAC/resources/configs/pipeline_config_abcd-options.yml index 1cb360cdc9..e385ce46dd 100644 --- a/CPAC/resources/configs/pipeline_config_abcd-options.yml +++ b/CPAC/resources/configs/pipeline_config_abcd-options.yml @@ -13,6 +13,14 @@ pipeline_setup: # Name for this pipeline configuration - useful for identification. # This string will be sanitized and used in filepaths pipeline_name: cpac_abcd-options + output_directory: + + # Quality control outputs + quality_control: + + # Generate eXtensible Connectivity Pipeline-style quality control files + generate_xcpqc_files: On + system_config: # The maximum amount of memory each participant's workflow can allocate. @@ -32,6 +40,12 @@ surface_analysis: abcd_prefreesurfer_prep: run: On + # Reference mask with 2mm resolution to be used during FNIRT-based brain extraction in ABCD-options pipeline. + ref_mask_res-2: /opt/dcan-tools/pipeline/global/templates/MNI152_T1_2mm_brain_mask_dil.nii.gz + + # Template with 2mm resolution to be used during FNIRT-based brain extraction in ABCD-options pipeline. + T1w_template_res-2: /opt/dcan-tools/pipeline/global/templates/MNI152_T1_2mm.nii.gz + # Will run Freesurfer for surface-based analysis. Will output traditional Freesurfer derivatives. # If you wish to employ Freesurfer outputs for brain masking or tissue segmentation in the voxel-based pipeline, # select those 'Freesurfer-' labeled options further below in anatomical_preproc. @@ -74,6 +88,9 @@ anatomical_preproc: # this is a fork option using: [FreeSurfer-ABCD] + restore_t1w_intensity: + run: On + # Non-local means filtering via ANTs DenoiseImage non_local_means_filtering: @@ -102,13 +119,6 @@ registration_workflows: anatomical_registration: run: On registration: - FSL-FNIRT: - - # Reference mask with 2mm resolution to be used during FNIRT-based brain extraction in ABCD-options pipeline. - ref_mask_res-2: /opt/dcan-tools/pipeline/global/templates/MNI152_T1_2mm_brain_mask_dil.nii.gz - - # Template with 2mm resolution to be used during FNIRT-based brain extraction in ABCD-options pipeline. - T1w_template_res-2: /opt/dcan-tools/pipeline/global/templates/MNI152_T1_2mm.nii.gz # option parameters ANTs: @@ -200,16 +210,18 @@ registration_workflows: run: On func_input_prep: - # Choose whether to use functional brain or skull as the input to functional-to-anatomical registration - reg_with_skull: On - # Choose whether to use the mean of the functional/EPI as the input to functional-to-anatomical registration or one of the volumes from the functional 4D timeseries that you choose. # input: ['Mean_Functional', 'Selected_Functional_Volume', 'fmriprep_reference'] input: [Selected_Functional_Volume] - # reference: 'brain' or 'restore-brain' - # In ABCD-options pipeline, 'restore-brain' is used as coregistration reference - reference: restore-brain + # Mask the sbref created by coregistration input prep nodeblocks above before registration + mask_sbref: Off + + boundary_based_registration: + + # this is a fork point + # run: [On, Off] - this will run both and fork the pipeline + run: [On] # Choose coregistration interpolation interpolation: spline @@ -259,14 +271,6 @@ functional_preproc: run: On motion_estimates_and_correction: run: On - motion_estimates: - - # calculate motion statistics BEFORE slice-timing correction - calculate_motion_first: On - - # calculate motion statistics AFTER motion correction - calculate_motion_after: Off - motion_correction: # using: ['3dvolreg', 'mcflirt'] @@ -290,21 +294,9 @@ functional_preproc: # Blip-FSL-TOPUP - Uses FSL TOPUP to calculate the distortion unwarp for EPI field maps of opposite/same phase encoding direction. using: [PhaseDiff, Blip-FSL-TOPUP] - func_masking: + template_space_func_masking: run: On - # Apply functional mask in native space - apply_func_mask_in_native_space: Off - - # using: ['AFNI', 'FSL', 'FSL_AFNI', 'Anatomical_Refined', 'Anatomical_Based', 'Anatomical_Resampled', 'CCS_Anatomical_Refined'] - # FSL_AFNI: fMRIPrep-style BOLD mask. Ref: https://github.com/nipreps/niworkflows/blob/a221f612/niworkflows/func/util.py#L246-L514 - # Anatomical_Refined: 1. binarize anat mask, in case it is not a binary mask. 2. fill holes of anat mask 3. init_bold_mask : input raw func → dilate init func brain mask 4. refined_bold_mask : input motion corrected func → dilate anatomical mask 5. get final func mask - # Anatomical_Based: Generate the BOLD mask by basing it off of the anatomical brain mask. Adapted from DCAN Lab's BOLD mask method from the ABCD pipeline. - # Anatomical_Resampled: Resample anatomical brain mask in standard space to get BOLD brain mask in standard space. Adapted from DCAN Lab's BOLD mask method from the ABCD pipeline. ("Create fMRI resolution standard space files for T1w image, wmparc, and brain mask […] don't use FLIRT to do spline interpolation with -applyisoxfm for the 2mm and 1mm cases because it doesn't know the peculiarities of the MNI template FOVs") - # CCS_Anatomical_Refined: Generate the BOLD mask by basing it off of the anatomical brain. Adapted from the BOLD mask method from the CCS pipeline. - # this is a fork point - using: [Anatomical_Resampled] - generate_func_mean: # Generate mean functional image diff --git a/CPAC/resources/configs/pipeline_config_abcd-prep.yml b/CPAC/resources/configs/pipeline_config_abcd-prep.yml index d6542ea358..27a4cd5f63 100644 --- a/CPAC/resources/configs/pipeline_config_abcd-prep.yml +++ b/CPAC/resources/configs/pipeline_config_abcd-prep.yml @@ -13,6 +13,14 @@ pipeline_setup: # Name for this pipeline configuration - useful for identification. # This string will be sanitized and used in filepaths pipeline_name: cpac_abcd-prep + output_directory: + + # Quality control outputs + quality_control: + + # Generate eXtensible Connectivity Pipeline-style quality control files + generate_xcpqc_files: On + system_config: # The maximum amount of memory each participant's workflow can allocate. @@ -32,6 +40,12 @@ surface_analysis: abcd_prefreesurfer_prep: run: On + # Reference mask with 2mm resolution to be used during FNIRT-based brain extraction in ABCD-options pipeline. + ref_mask_res-2: /opt/dcan-tools/pipeline/global/templates/MNI152_T1_2mm_brain_mask_dil.nii.gz + + # Template with 2mm resolution to be used during FNIRT-based brain extraction in ABCD-options pipeline. + T1w_template_res-2: /opt/dcan-tools/pipeline/global/templates/MNI152_T1_2mm.nii.gz + anatomical_preproc: run: On acpc_alignment: @@ -72,13 +86,6 @@ anatomical_preproc: registration_workflows: anatomical_registration: registration: - FSL-FNIRT: - - # Reference mask with 2mm resolution to be used during FNIRT-based brain extraction in ABCD-options pipeline. - ref_mask_res-2: /opt/dcan-tools/pipeline/global/templates/MNI152_T1_2mm_brain_mask_dil.nii.gz - - # Template with 2mm resolution to be used during FNIRT-based brain extraction in ABCD-options pipeline. - T1w_template_res-2: /opt/dcan-tools/pipeline/global/templates/MNI152_T1_2mm.nii.gz # option parameters ANTs: @@ -168,10 +175,6 @@ registration_workflows: # input: ['Mean_Functional', 'Selected_Functional_Volume', 'fmriprep_reference'] input: [Selected_Functional_Volume] - # reference: 'brain' or 'restore-brain' - # In ABCD-options pipeline, 'restore-brain' is used as coregistration reference - reference: restore-brain - # Choose coregistration interpolation interpolation: spline @@ -212,12 +215,6 @@ registration_workflows: interpolation: Linear functional_preproc: - motion_estimates_and_correction: - motion_estimates: - - # calculate motion statistics AFTER motion correction - calculate_motion_after: Off - distortion_correction: # using: ['PhaseDiff', 'Blip', 'Blip-FSL-TOPUP'] @@ -226,16 +223,5 @@ functional_preproc: # Blip-FSL-TOPUP - Uses FSL TOPUP to calculate the distortion unwarp for EPI field maps of opposite/same phase encoding direction. using: [] - func_masking: - - # Apply functional mask in native space - apply_func_mask_in_native_space: Off - - # using: ['AFNI', 'FSL', 'FSL_AFNI', 'Anatomical_Refined', 'Anatomical_Based', 'Anatomical_Resampled', 'CCS_Anatomical_Refined'] - # FSL_AFNI: fMRIPrep-style BOLD mask. Ref: https://github.com/nipreps/niworkflows/blob/a221f612/niworkflows/func/util.py#L246-L514 - # Anatomical_Refined: 1. binarize anat mask, in case it is not a binary mask. 2. fill holes of anat mask 3. init_bold_mask : input raw func → dilate init func brain mask 4. refined_bold_mask : input motion corrected func → dilate anatomical mask 5. get final func mask - # Anatomical_Based: Generate the BOLD mask by basing it off of the anatomical brain mask. Adapted from DCAN Lab's BOLD mask method from the ABCD pipeline. - # Anatomical_Resampled: Resample anatomical brain mask in standard space to get BOLD brain mask in standard space. Adapted from DCAN Lab's BOLD mask method from the ABCD pipeline. ("Create fMRI resolution standard space files for T1w image, wmparc, and brain mask […] don't use FLIRT to do spline interpolation with -applyisoxfm for the 2mm and 1mm cases because it doesn't know the peculiarities of the MNI template FOVs") - # CCS_Anatomical_Refined: Generate the BOLD mask by basing it off of the anatomical brain. Adapted from the BOLD mask method from the CCS pipeline. - # this is a fork point - using: [Anatomical_Resampled] + template_space_func_masking: + run: On diff --git a/CPAC/resources/configs/pipeline_config_benchmark-ANTS.yml b/CPAC/resources/configs/pipeline_config_benchmark-ANTS.yml index af356132a9..b42c30f547 100644 --- a/CPAC/resources/configs/pipeline_config_benchmark-ANTS.yml +++ b/CPAC/resources/configs/pipeline_config_benchmark-ANTS.yml @@ -18,6 +18,9 @@ pipeline_setup: # Quality control outputs quality_control: + # Generate eXtensible Connectivity Pipeline-style quality control files + generate_xcpqc_files: On + # Generate quality control pages containing preprocessing and derivative outputs. generate_quality_control_images: On diff --git a/CPAC/resources/configs/pipeline_config_benchmark-FNIRT.yml b/CPAC/resources/configs/pipeline_config_benchmark-FNIRT.yml index 63e8fc0c92..7026e1f2fd 100644 --- a/CPAC/resources/configs/pipeline_config_benchmark-FNIRT.yml +++ b/CPAC/resources/configs/pipeline_config_benchmark-FNIRT.yml @@ -18,6 +18,9 @@ pipeline_setup: # Quality control outputs quality_control: + # Generate eXtensible Connectivity Pipeline-style quality control files + generate_xcpqc_files: On + # Generate quality control pages containing preprocessing and derivative outputs. generate_quality_control_images: On diff --git a/CPAC/resources/configs/pipeline_config_blank.yml b/CPAC/resources/configs/pipeline_config_blank.yml index 7f09680fc6..75c889d9b2 100644 --- a/CPAC/resources/configs/pipeline_config_blank.yml +++ b/CPAC/resources/configs/pipeline_config_blank.yml @@ -11,6 +11,12 @@ pipeline_setup: # Name for this pipeline configuration - useful for identification. # This string will be sanitized and used in filepaths pipeline_name: cpac-blank-template + + # Human, non-human primate, or rodent data? + organism: human + + # Desired orientation for the output data. "RPI", "LPI", "RAI", "LAI", "RAS", "LAS", "RPS", "LPS" + desired_orientation: RPI output_directory: # Quality control outputs @@ -205,6 +211,12 @@ surface_analysis: abcd_prefreesurfer_prep: run: Off + # Reference mask with 2mm resolution to be used during FNIRT-based brain extraction in ABCD-options pipeline. + ref_mask_res-2: $FSLDIR/data/standard/MNI152_T1_2mm_brain_mask_dil.nii.gz + + # Template with 2mm resolution to be used during FNIRT-based brain extraction in ABCD-options pipeline. + T1w_template_res-2: $FSLDIR/data/standard/MNI152_T1_2mm.nii.gz + # Will run Freesurfer for surface-based analysis. Will output traditional Freesurfer derivatives. # If you wish to employ Freesurfer outputs for brain masking or tissue segmentation in the voxel-based pipeline, # select those 'Freesurfer-' labeled options further below in anatomical_preproc. @@ -242,6 +254,11 @@ surface_analysis: anatomical_preproc: run: Off + + # [warp] - Deoblique the input image using AFNI 3dWarp. Changes header and the image data. + # [refit] - Clear the header of the input image using AFNI 3drefit. Changes only the header. + # applies for both T1w and T2w images + deoblique: [refit] acpc_alignment: T1w_brain_ACPC_template: @@ -279,7 +296,7 @@ anatomical_preproc: FreeSurfer-BET: # Template to be used for FreeSurfer-BET brain extraction in CCS-options pipeline - T1w_brain_template_mask_ccs: /ccs_template/MNI152_T1_1mm_first_brain_mask.nii.gz + T1w_brain_template_mask_ccs: /code/CPAC/resources/templates/MNI152_T1_1mm_first_brain_mask.nii.gz # using: ['3dSkullStrip', 'BET', 'UNet', 'niworkflows-ants', 'FreeSurfer-ABCD', 'FreeSurfer-BET-Tight', 'FreeSurfer-BET-Loose', 'FreeSurfer-Brainmask'] # this is a fork option @@ -410,6 +427,9 @@ anatomical_preproc: # niworkflows-ants registration mask (can be optional) regmask_path: /ants_template/oasis/T_template0_BrainCerebellumRegistrationMask.nii.gz + restore_t1w_intensity: + run: Off + run_t2: Off # Bias field correction based on square root of T1w * T2w @@ -558,6 +578,9 @@ segmentation: WM_label: [2, 41] registration_workflows: + + # sink native transform files to the output directory + sink_native_transforms: Off anatomical_registration: run: Off registration: @@ -576,12 +599,6 @@ registration_workflows: # It is for monkey pipeline specifically. FNIRT_T1w_template: - # Reference mask with 2mm resolution to be used during FNIRT-based brain extraction in ABCD-options pipeline. - ref_mask_res-2: $FSLDIR/data/standard/MNI152_T1_2mm_brain_mask_dil.nii.gz - - # Template with 2mm resolution to be used during FNIRT-based brain extraction in ABCD-options pipeline. - T1w_template_res-2: $FSLDIR/data/standard/MNI152_T1_2mm.nii.gz - # Configuration file to be used by FSL to set FNIRT parameters. # It is not necessary to change this path unless you intend to use custom FNIRT parameters or a non-standard template. fnirt_config: T1_2_MNI152_2mm @@ -702,9 +719,6 @@ registration_workflows: run: Off func_input_prep: - # Choose whether to use functional brain or skull as the input to functional-to-anatomical registration - reg_with_skull: Off - # Choose whether to use the mean of the functional/EPI as the input to functional-to-anatomical registration or one of the volumes from the functional 4D timeseries that you choose. # input: ['Mean_Functional', 'Selected_Functional_Volume', 'fmriprep_reference'] input: [Mean_Functional] @@ -720,6 +734,9 @@ registration_workflows: #Input the index of which volume from the functional 4D timeseries input file you wish to use as the input for functional-to-anatomical registration. func_reg_input_volume: 0 + # Mask the sbref created by coregistration input prep nodeblocks above before registration + mask_sbref: On + boundary_based_registration: # this is a fork point @@ -741,16 +758,6 @@ registration_workflows: # It is not necessary to change this path unless you intend to use non-standard MNI registration. bbr_schedule: $FSLDIR/etc/flirtsch/bbr.sch - # reference: 'brain' or 'restore-brain' - # In ABCD-options pipeline, 'restore-brain' is used as coregistration reference - reference: brain - - # Choose FSL or ABCD as coregistration method - using: FSL - - # Choose brain or whole-head as coregistration input - input: brain - # Choose coregistration interpolation interpolation: trilinear @@ -954,6 +961,10 @@ functional_preproc: # Convert raw data from LPI to RPI run: On + # [warp] - Deoblique the input image using AFNI 3dWarp. Changes header and the image data. Applies interpolation to the slice-timing metadata. + # [refit] - Clear the header of the input image using AFNI 3drefit. Changes only the header. + deoblique: [refit] + slice_timing_correction: # Interpolate voxel time courses so they are sampled at the same time points. @@ -970,14 +981,6 @@ functional_preproc: motion_estimates_and_correction: run: Off - motion_estimates: - - # calculate motion statistics BEFORE slice-timing correction - calculate_motion_first: Off - - # calculate motion statistics AFTER motion correction - calculate_motion_after: On - motion_correction: # using: ['3dvolreg', 'mcflirt'] @@ -1140,11 +1143,10 @@ functional_preproc: # Apply functional mask in native space apply_func_mask_in_native_space: On - # using: ['AFNI', 'FSL', 'FSL_AFNI', 'Anatomical_Refined', 'Anatomical_Based', 'Anatomical_Resampled', 'CCS_Anatomical_Refined'] + # using: ['AFNI', 'FSL', 'FSL_AFNI', 'Anatomical_Refined', 'Anatomical_Based', 'CCS_Anatomical_Refined'] # FSL_AFNI: fMRIPrep-style BOLD mask. Ref: https://github.com/nipreps/niworkflows/blob/a221f612/niworkflows/func/util.py#L246-L514 # Anatomical_Refined: 1. binarize anat mask, in case it is not a binary mask. 2. fill holes of anat mask 3. init_bold_mask : input raw func → dilate init func brain mask 4. refined_bold_mask : input motion corrected func → dilate anatomical mask 5. get final func mask # Anatomical_Based: Generate the BOLD mask by basing it off of the anatomical brain mask. Adapted from DCAN Lab's BOLD mask method from the ABCD pipeline. - # Anatomical_Resampled: Resample anatomical brain mask in standard space to get BOLD brain mask in standard space. Adapted from DCAN Lab's BOLD mask method from the ABCD pipeline. ("Create fMRI resolution standard space files for T1w image, wmparc, and brain mask […] don't use FLIRT to do spline interpolation with -applyisoxfm for the 2mm and 1mm cases because it doesn't know the peculiarities of the MNI template FOVs") # CCS_Anatomical_Refined: Generate the BOLD mask by basing it off of the anatomical brain. Adapted from the BOLD mask method from the CCS pipeline. # this is a fork point using: [AFNI] @@ -1153,6 +1155,12 @@ functional_preproc: # Choose whether or not to dilate the anatomical mask if you choose 'Anatomical_Refined' as the functional masking option. It will dilate one voxel if enabled. anatomical_mask_dilation: Off + template_space_func_masking: + run: Off + + # Anatomical_Resampled: Resample anatomical brain mask in standard space to get BOLD brain mask in standard space. Adapted from DCAN Lab's BOLD mask method from the ABCD pipeline. ("Create fMRI resolution standard space files for T1w image, wmparc, and brain mask […] don't use FLIRT to do spline interpolation with -applyisoxfm for the 2mm and 1mm cases because it doesn't know the peculiarities of the MNI template FOVs") + using: [Anatomical_Resampled] + generate_func_mean: # Generate mean functional image diff --git a/CPAC/resources/configs/pipeline_config_ccs-options.yml b/CPAC/resources/configs/pipeline_config_ccs-options.yml index f73cedec84..1a4d59c7eb 100644 --- a/CPAC/resources/configs/pipeline_config_ccs-options.yml +++ b/CPAC/resources/configs/pipeline_config_ccs-options.yml @@ -18,6 +18,9 @@ pipeline_setup: # Quality control outputs quality_control: + # Generate eXtensible Connectivity Pipeline-style quality control files + generate_xcpqc_files: On + # Generate quality control pages containing preprocessing and derivative outputs. generate_quality_control_images: On @@ -58,10 +61,6 @@ anatomical_preproc: brain_extraction: run: On - FreeSurfer-BET: - - # Template to be used for FreeSurfer-BET brain extraction in CCS-options pipeline - T1w_brain_template_mask_ccs: /code/CPAC/resources/templates/MNI152_T1_1mm_first_brain_mask.nii.gz # using: ['3dSkullStrip', 'BET', 'UNet', 'niworkflows-ants', 'FreeSurfer-ABCD', 'FreeSurfer-BET-Tight', 'FreeSurfer-BET-Loose', 'FreeSurfer-Brainmask'] # this is a fork option @@ -180,11 +179,10 @@ functional_preproc: func_masking: run: On - # using: ['AFNI', 'FSL', 'FSL_AFNI', 'Anatomical_Refined', 'Anatomical_Based', 'Anatomical_Resampled', 'CCS_Anatomical_Refined'] + # using: ['AFNI', 'FSL', 'FSL_AFNI', 'Anatomical_Refined', 'Anatomical_Based', 'CCS_Anatomical_Refined'] # FSL_AFNI: fMRIPrep-style BOLD mask. Ref: https://github.com/nipreps/niworkflows/blob/a221f612/niworkflows/func/util.py#L246-L514 # Anatomical_Refined: 1. binarize anat mask, in case it is not a binary mask. 2. fill holes of anat mask 3. init_bold_mask : input raw func → dilate init func brain mask 4. refined_bold_mask : input motion corrected func → dilate anatomical mask 5. get final func mask # Anatomical_Based: Generate the BOLD mask by basing it off of the anatomical brain mask. Adapted from DCAN Lab's BOLD mask method from the ABCD pipeline. - # Anatomical_Resampled: Resample anatomical brain mask in standard space to get BOLD brain mask in standard space. Adapted from DCAN Lab's BOLD mask method from the ABCD pipeline. ("Create fMRI resolution standard space files for T1w image, wmparc, and brain mask […] don't use FLIRT to do spline interpolation with -applyisoxfm for the 2mm and 1mm cases because it doesn't know the peculiarities of the MNI template FOVs") # CCS_Anatomical_Refined: Generate the BOLD mask by basing it off of the anatomical brain. Adapted from the BOLD mask method from the CCS pipeline. # this is a fork point using: [CCS_Anatomical_Refined] diff --git a/CPAC/resources/configs/pipeline_config_default-deprecated.yml b/CPAC/resources/configs/pipeline_config_default-deprecated.yml index cc768ce714..22c0e5dada 100644 --- a/CPAC/resources/configs/pipeline_config_default-deprecated.yml +++ b/CPAC/resources/configs/pipeline_config_default-deprecated.yml @@ -18,6 +18,9 @@ pipeline_setup: # Quality control outputs quality_control: + # Generate eXtensible Connectivity Pipeline-style quality control files + generate_xcpqc_files: On + # Generate quality control pages containing preprocessing and derivative outputs. generate_quality_control_images: On diff --git a/CPAC/resources/configs/pipeline_config_default.yml b/CPAC/resources/configs/pipeline_config_default.yml index b7aa56c13f..5c42e5ad35 100644 --- a/CPAC/resources/configs/pipeline_config_default.yml +++ b/CPAC/resources/configs/pipeline_config_default.yml @@ -13,6 +13,12 @@ pipeline_setup: # This string will be sanitized and used in filepaths pipeline_name: cpac-default-pipeline + # Human, non-human primate, or rodent data? + organism: human + + # Desired orientation for the output data. "RPI", "LPI", "RAI", "LAI", "RAS", "LAS", "RPS", "LPS" + desired_orientation: RPI + output_directory: # Directory where C-PAC should write out processed data, logs, and crash reports. @@ -51,10 +57,10 @@ pipeline_setup: # Quality control outputs quality_control: # Generate quality control pages containing preprocessing and derivative outputs. - generate_quality_control_images: True + generate_quality_control_images: On # Generate eXtensible Connectivity Pipeline-style quality control files - generate_xcpqc_files: False + generate_xcpqc_files: On working_directory: @@ -197,6 +203,12 @@ surface_analysis: abcd_prefreesurfer_prep: run: Off + # Reference mask with 2mm resolution to be used during FNIRT-based brain extraction in ABCD-options pipeline. + ref_mask_res-2: $FSLDIR/data/standard/MNI152_T1_2mm_brain_mask_dil.nii.gz + + # Template with 2mm resolution to be used during FNIRT-based brain extraction in ABCD-options pipeline. + T1w_template_res-2: $FSLDIR/data/standard/MNI152_T1_2mm.nii.gz + # Will run Freesurfer for surface-based analysis. Will output traditional Freesurfer derivatives. # If you wish to employ Freesurfer outputs for brain masking or tissue segmentation in the voxel-based pipeline, # select those 'Freesurfer-' labeled options further below in anatomical_preproc. @@ -279,6 +291,11 @@ anatomical_preproc: run_t2: Off + # [warp] - Deoblique the input image using AFNI 3dWarp. Changes header and the image data. + # [refit] - Clear the header of the input image using AFNI 3drefit. Changes only the header. + # applies for both T1w and T2w images + deoblique: ["refit"] + # Non-local means filtering via ANTs DenoiseImage non_local_means_filtering: @@ -473,7 +490,10 @@ anatomical_preproc: FreeSurfer-BET: # Template to be used for FreeSurfer-BET brain extraction in CCS-options pipeline - T1w_brain_template_mask_ccs: /ccs_template/MNI152_T1_1mm_first_brain_mask.nii.gz + T1w_brain_template_mask_ccs: /code/CPAC/resources/templates/MNI152_T1_1mm_first_brain_mask.nii.gz + + restore_t1w_intensity: + run: Off segmentation: @@ -606,6 +626,8 @@ segmentation: registration_workflows: + # sink native transform files to the output directory + sink_native_transforms: Off anatomical_registration: @@ -738,12 +760,6 @@ registration_workflows: # It is not necessary to change this path unless you intend to use a different template. identity_matrix: $FSLDIR/etc/flirtsch/ident.mat - # Reference mask with 2mm resolution to be used during FNIRT-based brain extraction in ABCD-options pipeline. - ref_mask_res-2: $FSLDIR/data/standard/MNI152_T1_2mm_brain_mask_dil.nii.gz - - # Template with 2mm resolution to be used during FNIRT-based brain extraction in ABCD-options pipeline. - T1w_template_res-2: $FSLDIR/data/standard/MNI152_T1_2mm.nii.gz - overwrite_transform: run: Off @@ -759,16 +775,6 @@ registration_workflows: run: On - # reference: 'brain' or 'restore-brain' - # In ABCD-options pipeline, 'restore-brain' is used as coregistration reference - reference: brain - - # Choose FSL or ABCD as coregistration method - using: FSL - - # Choose brain or whole-head as coregistration input - input: brain - # Choose coregistration interpolation interpolation: trilinear @@ -783,9 +789,6 @@ registration_workflows: func_input_prep: - # Choose whether to use functional brain or skull as the input to functional-to-anatomical registration - reg_with_skull: Off - # Choose whether to use the mean of the functional/EPI as the input to functional-to-anatomical registration or one of the volumes from the functional 4D timeseries that you choose. # input: ['Mean_Functional', 'Selected_Functional_Volume', 'fmriprep_reference'] input: ['Mean_Functional'] @@ -802,19 +805,22 @@ registration_workflows: #Input the index of which volume from the functional 4D timeseries input file you wish to use as the input for functional-to-anatomical registration. func_reg_input_volume: 0 + # Mask the sbref created by coregistration input prep nodeblocks above before registration + mask_sbref: On + boundary_based_registration: # this is a fork point # run: [On, Off] - this will run both and fork the pipeline run: [On] - # Standard FSL 5.0 Scheduler used for Boundary Based Registration. - # It is not necessary to change this path unless you intend to use non-standard MNI registration. - bbr_schedule: $FSLDIR/etc/flirtsch/bbr.sch - # reference for boundary based registration # options: 'whole-head' or 'brain' reference: whole-head + # Standard FSL 5.0 Scheduler used for Boundary Based Registration. + # It is not necessary to change this path unless you intend to use non-standard MNI registration. + bbr_schedule: $FSLDIR/etc/flirtsch/bbr.sch + # choose which FAST map to generate BBR WM mask # options: 'probability_map', 'partial_volume_map' bbr_wm_map: 'probability_map' @@ -1023,6 +1029,10 @@ functional_preproc: # Convert raw data from LPI to RPI run: On + # [warp] - Deoblique the input image using AFNI 3dWarp. Changes header and the image data. Applies interpolation to the slice-timing metadata. + # [refit] - Clear the header of the input image using AFNI 3drefit. Changes only the header. + deoblique: ["refit"] + truncation: # First timepoint to include in analysis. @@ -1070,14 +1080,6 @@ functional_preproc: run: On - motion_estimates: - - # calculate motion statistics BEFORE slice-timing correction - calculate_motion_first: Off - - # calculate motion statistics AFTER motion correction - calculate_motion_after: On - motion_correction: # using: ['3dvolreg', 'mcflirt'] @@ -1257,12 +1259,11 @@ functional_preproc: func_masking: run: On - # using: ['AFNI', 'FSL', 'FSL_AFNI', 'Anatomical_Refined', 'Anatomical_Based', 'Anatomical_Resampled', 'CCS_Anatomical_Refined'] + # using: ['AFNI', 'FSL', 'FSL_AFNI', 'Anatomical_Refined', 'Anatomical_Based', 'CCS_Anatomical_Refined'] # FSL_AFNI: fMRIPrep-style BOLD mask. Ref: https://github.com/nipreps/niworkflows/blob/a221f612/niworkflows/func/util.py#L246-L514 # Anatomical_Refined: 1. binarize anat mask, in case it is not a binary mask. 2. fill holes of anat mask 3. init_bold_mask : input raw func → dilate init func brain mask 4. refined_bold_mask : input motion corrected func → dilate anatomical mask 5. get final func mask # Anatomical_Based: Generate the BOLD mask by basing it off of the anatomical brain mask. Adapted from DCAN Lab's BOLD mask method from the ABCD pipeline. - # Anatomical_Resampled: Resample anatomical brain mask in standard space to get BOLD brain mask in standard space. Adapted from DCAN Lab's BOLD mask method from the ABCD pipeline. ("Create fMRI resolution standard space files for T1w image, wmparc, and brain mask […] don't use FLIRT to do spline interpolation with -applyisoxfm for the 2mm and 1mm cases because it doesn't know the peculiarities of the MNI template FOVs") # CCS_Anatomical_Refined: Generate the BOLD mask by basing it off of the anatomical brain. Adapted from the BOLD mask method from the CCS pipeline. # this is a fork point @@ -1335,6 +1336,11 @@ functional_preproc: # Apply functional mask in native space apply_func_mask_in_native_space: On + template_space_func_masking: + run: Off + # Anatomical_Resampled: Resample anatomical brain mask in standard space to get BOLD brain mask in standard space. Adapted from DCAN Lab's BOLD mask method from the ABCD pipeline. ("Create fMRI resolution standard space files for T1w image, wmparc, and brain mask […] don't use FLIRT to do spline interpolation with -applyisoxfm for the 2mm and 1mm cases because it doesn't know the peculiarities of the MNI template FOVs") + using: [Anatomical_Resampled] + generate_func_mean: # Generate mean functional image diff --git a/CPAC/resources/configs/pipeline_config_fmriprep-ingress.yml b/CPAC/resources/configs/pipeline_config_fmriprep-ingress.yml index da6b97142f..275a7d8d1f 100644 --- a/CPAC/resources/configs/pipeline_config_fmriprep-ingress.yml +++ b/CPAC/resources/configs/pipeline_config_fmriprep-ingress.yml @@ -18,6 +18,9 @@ pipeline_setup: # Quality control outputs quality_control: + # Generate eXtensible Connectivity Pipeline-style quality control files + generate_xcpqc_files: On + # Generate quality control pages containing preprocessing and derivative outputs. generate_quality_control_images: On diff --git a/CPAC/resources/configs/pipeline_config_fmriprep-options.yml b/CPAC/resources/configs/pipeline_config_fmriprep-options.yml index 555b52302d..5453144af5 100644 --- a/CPAC/resources/configs/pipeline_config_fmriprep-options.yml +++ b/CPAC/resources/configs/pipeline_config_fmriprep-options.yml @@ -13,6 +13,14 @@ pipeline_setup: # Name for this pipeline configuration - useful for identification. # This string will be sanitized and used in filepaths pipeline_name: cpac_fmriprep-options + output_directory: + + # Quality control outputs + quality_control: + + # Generate eXtensible Connectivity Pipeline-style quality control files + generate_xcpqc_files: On + system_config: # Select Off if you intend to run CPAC on a single machine. @@ -151,12 +159,6 @@ registration_workflows: registration: FSL-FNIRT: - # Reference mask with 2mm resolution to be used during FNIRT-based brain extraction in ABCD-options pipeline. - ref_mask_res-2: - - # Template with 2mm resolution to be used during FNIRT-based brain extraction in ABCD-options pipeline. - T1w_template_res-2: - # Reference mask for FSL registration. ref_mask: @@ -343,11 +345,6 @@ functional_preproc: motion_estimates_and_correction: run: On - motion_estimates: - - # calculate motion statistics BEFORE slice-timing correction - calculate_motion_first: On - motion_correction: # using: ['3dvolreg', 'mcflirt'] @@ -372,11 +369,10 @@ functional_preproc: brain_mask: /code/CPAC/resources/templates/tpl-MNI152NLin2009cAsym_res-02_desc-brain_mask.nii.gz brain_probseg: /code/CPAC/resources/templates/tpl-MNI152NLin2009cAsym_res-01_label-brain_probseg.nii.gz - # using: ['AFNI', 'FSL', 'FSL_AFNI', 'Anatomical_Refined', 'Anatomical_Based', 'Anatomical_Resampled', 'CCS_Anatomical_Refined'] + # using: ['AFNI', 'FSL', 'FSL_AFNI', 'Anatomical_Refined', 'Anatomical_Based', 'CCS_Anatomical_Refined'] # FSL_AFNI: fMRIPrep-style BOLD mask. Ref: https://github.com/nipreps/niworkflows/blob/a221f612/niworkflows/func/util.py#L246-L514 # Anatomical_Refined: 1. binarize anat mask, in case it is not a binary mask. 2. fill holes of anat mask 3. init_bold_mask : input raw func → dilate init func brain mask 4. refined_bold_mask : input motion corrected func → dilate anatomical mask 5. get final func mask # Anatomical_Based: Generate the BOLD mask by basing it off of the anatomical brain mask. Adapted from DCAN Lab's BOLD mask method from the ABCD pipeline. - # Anatomical_Resampled: Resample anatomical brain mask in standard space to get BOLD brain mask in standard space. Adapted from DCAN Lab's BOLD mask method from the ABCD pipeline. ("Create fMRI resolution standard space files for T1w image, wmparc, and brain mask […] don't use FLIRT to do spline interpolation with -applyisoxfm for the 2mm and 1mm cases because it doesn't know the peculiarities of the MNI template FOVs") # CCS_Anatomical_Refined: Generate the BOLD mask by basing it off of the anatomical brain. Adapted from the BOLD mask method from the CCS pipeline. # this is a fork point using: [FSL_AFNI] diff --git a/CPAC/resources/configs/pipeline_config_fx-options.yml b/CPAC/resources/configs/pipeline_config_fx-options.yml index e65fd62483..f8cc2f8de6 100644 --- a/CPAC/resources/configs/pipeline_config_fx-options.yml +++ b/CPAC/resources/configs/pipeline_config_fx-options.yml @@ -13,13 +13,6 @@ pipeline_setup: # Name for this pipeline configuration - useful for identification. # This string will be sanitized and used in filepaths pipeline_name: cpac_fx-options - output_directory: - - # Quality control outputs - quality_control: - - # Generate eXtensible Connectivity Pipeline-style quality control files - generate_xcpqc_files: On nuisance_corrections: 2-nuisance_regression: diff --git a/CPAC/resources/configs/pipeline_config_monkey-ABCD.yml b/CPAC/resources/configs/pipeline_config_monkey-ABCD.yml index e1fb1e8e66..dfca2e5e46 100644 --- a/CPAC/resources/configs/pipeline_config_monkey-ABCD.yml +++ b/CPAC/resources/configs/pipeline_config_monkey-ABCD.yml @@ -13,11 +13,17 @@ pipeline_setup: # Name for this pipeline configuration - useful for identification. # This string will be sanitized and used in filepaths pipeline_name: cpac_default_monkey_skullstrip + + # Human, non-human primate, or rodent data? + organism: non-human primate output_directory: # Quality control outputs quality_control: + # Generate eXtensible Connectivity Pipeline-style quality control files + generate_xcpqc_files: On + # Generate quality control pages containing preprocessing and derivative outputs. generate_quality_control_images: On @@ -322,11 +328,10 @@ functional_preproc: brain_mask: brain_probseg: - # using: ['AFNI', 'FSL', 'FSL_AFNI', 'Anatomical_Refined', 'Anatomical_Based', 'Anatomical_Resampled', 'CCS_Anatomical_Refined'] + # using: ['AFNI', 'FSL', 'FSL_AFNI', 'Anatomical_Refined', 'Anatomical_Based', 'CCS_Anatomical_Refined'] # FSL_AFNI: fMRIPrep-style BOLD mask. Ref: https://github.com/nipreps/niworkflows/blob/a221f612/niworkflows/func/util.py#L246-L514 # Anatomical_Refined: 1. binarize anat mask, in case it is not a binary mask. 2. fill holes of anat mask 3. init_bold_mask : input raw func → dilate init func brain mask 4. refined_bold_mask : input motion corrected func → dilate anatomical mask 5. get final func mask # Anatomical_Based: Generate the BOLD mask by basing it off of the anatomical brain mask. Adapted from DCAN Lab's BOLD mask method from the ABCD pipeline. - # Anatomical_Resampled: Resample anatomical brain mask in standard space to get BOLD brain mask in standard space. Adapted from DCAN Lab's BOLD mask method from the ABCD pipeline. ("Create fMRI resolution standard space files for T1w image, wmparc, and brain mask […] don't use FLIRT to do spline interpolation with -applyisoxfm for the 2mm and 1mm cases because it doesn't know the peculiarities of the MNI template FOVs") # CCS_Anatomical_Refined: Generate the BOLD mask by basing it off of the anatomical brain. Adapted from the BOLD mask method from the CCS pipeline. # this is a fork point using: [Anatomical_Based] diff --git a/CPAC/resources/configs/pipeline_config_monkey.yml b/CPAC/resources/configs/pipeline_config_monkey.yml index 17b1396759..98c6ffe5f1 100644 --- a/CPAC/resources/configs/pipeline_config_monkey.yml +++ b/CPAC/resources/configs/pipeline_config_monkey.yml @@ -13,11 +13,17 @@ pipeline_setup: # Name for this pipeline configuration - useful for identification. # This string will be sanitized and used in filepaths pipeline_name: cpac_default_monkey_skullstrip + + # Human, non-human primate, or rodent data? + organism: non-human primate output_directory: # Quality control outputs quality_control: + # Generate eXtensible Connectivity Pipeline-style quality control files + generate_xcpqc_files: On + # Generate quality control pages containing preprocessing and derivative outputs. generate_quality_control_images: On @@ -249,11 +255,10 @@ functional_preproc: func_masking: run: On - # using: ['AFNI', 'FSL', 'FSL_AFNI', 'Anatomical_Refined', 'Anatomical_Based', 'Anatomical_Resampled', 'CCS_Anatomical_Refined'] + # using: ['AFNI', 'FSL', 'FSL_AFNI', 'Anatomical_Refined', 'Anatomical_Based', 'CCS_Anatomical_Refined'] # FSL_AFNI: fMRIPrep-style BOLD mask. Ref: https://github.com/nipreps/niworkflows/blob/a221f612/niworkflows/func/util.py#L246-L514 # Anatomical_Refined: 1. binarize anat mask, in case it is not a binary mask. 2. fill holes of anat mask 3. init_bold_mask : input raw func → dilate init func brain mask 4. refined_bold_mask : input motion corrected func → dilate anatomical mask 5. get final func mask # Anatomical_Based: Generate the BOLD mask by basing it off of the anatomical brain mask. Adapted from DCAN Lab's BOLD mask method from the ABCD pipeline. - # Anatomical_Resampled: Resample anatomical brain mask in standard space to get BOLD brain mask in standard space. Adapted from DCAN Lab's BOLD mask method from the ABCD pipeline. ("Create fMRI resolution standard space files for T1w image, wmparc, and brain mask […] don't use FLIRT to do spline interpolation with -applyisoxfm for the 2mm and 1mm cases because it doesn't know the peculiarities of the MNI template FOVs") # CCS_Anatomical_Refined: Generate the BOLD mask by basing it off of the anatomical brain. Adapted from the BOLD mask method from the CCS pipeline. # this is a fork point using: [Anatomical_Refined] diff --git a/CPAC/resources/configs/pipeline_config_ndmg.yml b/CPAC/resources/configs/pipeline_config_ndmg.yml index af183e82c1..bd77602300 100644 --- a/CPAC/resources/configs/pipeline_config_ndmg.yml +++ b/CPAC/resources/configs/pipeline_config_ndmg.yml @@ -18,6 +18,9 @@ pipeline_setup: # Quality control outputs quality_control: + # Generate eXtensible Connectivity Pipeline-style quality control files + generate_xcpqc_files: On + # Generate quality control pages containing preprocessing and derivative outputs. generate_quality_control_images: On diff --git a/CPAC/resources/configs/pipeline_config_rbc-options.yml b/CPAC/resources/configs/pipeline_config_rbc-options.yml index 0332d1a975..27fae8a7ac 100644 --- a/CPAC/resources/configs/pipeline_config_rbc-options.yml +++ b/CPAC/resources/configs/pipeline_config_rbc-options.yml @@ -13,14 +13,6 @@ pipeline_setup: # Name for this pipeline configuration - useful for identification. # This string will be sanitized and used in filepaths pipeline_name: RBCv0 - output_directory: - - # Quality control outputs - quality_control: - - # Generate eXtensible Connectivity Pipeline-style quality control files - generate_xcpqc_files: On - system_config: # Stop worklow execution on first crash? diff --git a/CPAC/resources/configs/pipeline_config_regtest-1.yml b/CPAC/resources/configs/pipeline_config_regtest-1.yml index 22b0506092..ee1ff5d5cf 100644 --- a/CPAC/resources/configs/pipeline_config_regtest-1.yml +++ b/CPAC/resources/configs/pipeline_config_regtest-1.yml @@ -18,6 +18,9 @@ pipeline_setup: # Quality control outputs quality_control: + # Generate eXtensible Connectivity Pipeline-style quality control files + generate_xcpqc_files: On + # Generate quality control pages containing preprocessing and derivative outputs. generate_quality_control_images: On diff --git a/CPAC/resources/configs/pipeline_config_regtest-2.yml b/CPAC/resources/configs/pipeline_config_regtest-2.yml index 574f9a6f4c..e5b6dbd626 100644 --- a/CPAC/resources/configs/pipeline_config_regtest-2.yml +++ b/CPAC/resources/configs/pipeline_config_regtest-2.yml @@ -18,6 +18,9 @@ pipeline_setup: # Quality control outputs quality_control: + # Generate eXtensible Connectivity Pipeline-style quality control files + generate_xcpqc_files: On + # Generate quality control pages containing preprocessing and derivative outputs. generate_quality_control_images: On diff --git a/CPAC/resources/configs/pipeline_config_regtest-3.yml b/CPAC/resources/configs/pipeline_config_regtest-3.yml index 876e14cc58..804e4f5f2f 100644 --- a/CPAC/resources/configs/pipeline_config_regtest-3.yml +++ b/CPAC/resources/configs/pipeline_config_regtest-3.yml @@ -18,6 +18,9 @@ pipeline_setup: # Quality control outputs quality_control: + # Generate eXtensible Connectivity Pipeline-style quality control files + generate_xcpqc_files: On + # Generate quality control pages containing preprocessing and derivative outputs. generate_quality_control_images: On @@ -161,11 +164,6 @@ functional_preproc: motion_estimates_and_correction: run: On - motion_estimates: - - # calculate motion statistics BEFORE slice-timing correction - calculate_motion_first: On - motion_correction: # using: ['3dvolreg', 'mcflirt'] @@ -194,11 +192,10 @@ functional_preproc: FSL_AFNI: bold_ref: /code/CPAC/resources/templates/tpl-MNI152NLin2009cAsym_res-02_desc-fMRIPrep_boldref.nii.gz - # using: ['AFNI', 'FSL', 'FSL_AFNI', 'Anatomical_Refined', 'Anatomical_Based', 'Anatomical_Resampled', 'CCS_Anatomical_Refined'] + # using: ['AFNI', 'FSL', 'FSL_AFNI', 'Anatomical_Refined', 'Anatomical_Based', 'CCS_Anatomical_Refined'] # FSL_AFNI: fMRIPrep-style BOLD mask. Ref: https://github.com/nipreps/niworkflows/blob/a221f612/niworkflows/func/util.py#L246-L514 # Anatomical_Refined: 1. binarize anat mask, in case it is not a binary mask. 2. fill holes of anat mask 3. init_bold_mask : input raw func → dilate init func brain mask 4. refined_bold_mask : input motion corrected func → dilate anatomical mask 5. get final func mask # Anatomical_Based: Generate the BOLD mask by basing it off of the anatomical brain mask. Adapted from DCAN Lab's BOLD mask method from the ABCD pipeline. - # Anatomical_Resampled: Resample anatomical brain mask in standard space to get BOLD brain mask in standard space. Adapted from DCAN Lab's BOLD mask method from the ABCD pipeline. ("Create fMRI resolution standard space files for T1w image, wmparc, and brain mask […] don't use FLIRT to do spline interpolation with -applyisoxfm for the 2mm and 1mm cases because it doesn't know the peculiarities of the MNI template FOVs") # CCS_Anatomical_Refined: Generate the BOLD mask by basing it off of the anatomical brain. Adapted from the BOLD mask method from the CCS pipeline. # this is a fork point using: [FSL_AFNI] diff --git a/CPAC/resources/configs/pipeline_config_regtest-4.yml b/CPAC/resources/configs/pipeline_config_regtest-4.yml index 534a5cf6b7..5232abdbf5 100644 --- a/CPAC/resources/configs/pipeline_config_regtest-4.yml +++ b/CPAC/resources/configs/pipeline_config_regtest-4.yml @@ -18,6 +18,9 @@ pipeline_setup: # Quality control outputs quality_control: + # Generate eXtensible Connectivity Pipeline-style quality control files + generate_xcpqc_files: On + # Generate quality control pages containing preprocessing and derivative outputs. generate_quality_control_images: On @@ -189,11 +192,6 @@ functional_preproc: motion_estimates_and_correction: run: On - motion_estimates: - - # calculate motion statistics BEFORE slice-timing correction - calculate_motion_first: On - motion_correction: # using: ['3dvolreg', 'mcflirt'] @@ -211,11 +209,10 @@ functional_preproc: func_masking: run: On - # using: ['AFNI', 'FSL', 'FSL_AFNI', 'Anatomical_Refined', 'Anatomical_Based', 'Anatomical_Resampled', 'CCS_Anatomical_Refined'] + # using: ['AFNI', 'FSL', 'FSL_AFNI', 'Anatomical_Refined', 'Anatomical_Based', 'CCS_Anatomical_Refined'] # FSL_AFNI: fMRIPrep-style BOLD mask. Ref: https://github.com/nipreps/niworkflows/blob/a221f612/niworkflows/func/util.py#L246-L514 # Anatomical_Refined: 1. binarize anat mask, in case it is not a binary mask. 2. fill holes of anat mask 3. init_bold_mask : input raw func → dilate init func brain mask 4. refined_bold_mask : input motion corrected func → dilate anatomical mask 5. get final func mask # Anatomical_Based: Generate the BOLD mask by basing it off of the anatomical brain mask. Adapted from DCAN Lab's BOLD mask method from the ABCD pipeline. - # Anatomical_Resampled: Resample anatomical brain mask in standard space to get BOLD brain mask in standard space. Adapted from DCAN Lab's BOLD mask method from the ABCD pipeline. ("Create fMRI resolution standard space files for T1w image, wmparc, and brain mask […] don't use FLIRT to do spline interpolation with -applyisoxfm for the 2mm and 1mm cases because it doesn't know the peculiarities of the MNI template FOVs") # CCS_Anatomical_Refined: Generate the BOLD mask by basing it off of the anatomical brain. Adapted from the BOLD mask method from the CCS pipeline. # this is a fork point using: [Anatomical_Refined] diff --git a/CPAC/resources/configs/pipeline_config_rodent.yml b/CPAC/resources/configs/pipeline_config_rodent.yml index a066241431..a5f2c6caf9 100644 --- a/CPAC/resources/configs/pipeline_config_rodent.yml +++ b/CPAC/resources/configs/pipeline_config_rodent.yml @@ -13,11 +13,17 @@ pipeline_setup: # Name for this pipeline configuration - useful for identification. # This string will be sanitized and used in filepaths pipeline_name: analysis + + # Human, non-human primate, or rodent data? + organism: rodent output_directory: # Quality control outputs quality_control: + # Generate eXtensible Connectivity Pipeline-style quality control files + generate_xcpqc_files: On + # Generate quality control pages containing preprocessing and derivative outputs. generate_quality_control_images: On @@ -212,11 +218,10 @@ functional_preproc: # Robust brain center estimation. Mutually exclusive with functional,reduce_bias,robust,padding,remove_eyes,surfaces robust: On - # using: ['AFNI', 'FSL', 'FSL_AFNI', 'Anatomical_Refined', 'Anatomical_Based', 'Anatomical_Resampled', 'CCS_Anatomical_Refined'] + # using: ['AFNI', 'FSL', 'FSL_AFNI', 'Anatomical_Refined', 'Anatomical_Based', 'CCS_Anatomical_Refined'] # FSL_AFNI: fMRIPrep-style BOLD mask. Ref: https://github.com/nipreps/niworkflows/blob/a221f612/niworkflows/func/util.py#L246-L514 # Anatomical_Refined: 1. binarize anat mask, in case it is not a binary mask. 2. fill holes of anat mask 3. init_bold_mask : input raw func → dilate init func brain mask 4. refined_bold_mask : input motion corrected func → dilate anatomical mask 5. get final func mask # Anatomical_Based: Generate the BOLD mask by basing it off of the anatomical brain mask. Adapted from DCAN Lab's BOLD mask method from the ABCD pipeline. - # Anatomical_Resampled: Resample anatomical brain mask in standard space to get BOLD brain mask in standard space. Adapted from DCAN Lab's BOLD mask method from the ABCD pipeline. ("Create fMRI resolution standard space files for T1w image, wmparc, and brain mask […] don't use FLIRT to do spline interpolation with -applyisoxfm for the 2mm and 1mm cases because it doesn't know the peculiarities of the MNI template FOVs") # CCS_Anatomical_Refined: Generate the BOLD mask by basing it off of the anatomical brain. Adapted from the BOLD mask method from the CCS pipeline. # this is a fork point using: [FSL] diff --git a/CPAC/resources/cpac_outputs.tsv b/CPAC/resources/cpac_outputs.tsv index 873defbbff..d7b5661279 100644 --- a/CPAC/resources/cpac_outputs.tsv +++ b/CPAC/resources/cpac_outputs.tsv @@ -163,10 +163,10 @@ desc-preproc_T1w T1w T1w anat NIfTI desc-reorient_T1w T1w T1w anat NIfTI Yes desc-restore_T1w T1w T1w anat NIfTI desc-restore-brain_T1w T1w T1w anat NIfTI +desc-ABCDpreproc_T1w T1w T1w anat NIfTI space-template_desc-brain_T1w T1w template anat NIfTI Yes space-template_desc-preproc_T1w T1w template anat NIfTI space-template_desc-head_T1w T1w template anat NIfTI -space-template_desc-T1w_mask mask template anat NIfTI space-template_desc-Mean_timeseries timeseries func 1D desc-MeanSCA_timeseries timeseries func 1D desc-SpatReg_timeseries timeseries func 1D @@ -210,6 +210,37 @@ from-template_to-longitudinal_mode-image_xfm xfm anat NIfTI from-template_to-T1w_mode-image_desc-linear_xfm xfm anat NIfTI from-template_to-T1w_mode-image_desc-nonlinear_xfm xfm anat NIfTI from-template_to-T1w_mode-image_xfm xfm anat NIfTI +from-T1w_to-template_mode-image_desc-flirt_xfm xfm anat MAT +from-T1w_to-symtemplate_mode-image_desc-flirt_xfm xfm anat MAT +from-longitudinal_to-template_mode-image_desc-flirt_xfm xfm anat MAT +from-template_to-T1w_mode-image_desc-flirt_xfm xfm anat MAT +from-symtemplate_to-T1w_mode-image_desc-flirt_xfm xfm anat MAT +from-template_to-longitudinal_mode-image_desc-flirt_xfm xfm anat MAT +from-longitudinal_to-symtemplate_mode-image_desc-flirt_xfm xfm anat MAT +from-symtemplate_to-longitudinal_mode-image_desc-flirt_xfm xfm anat MAT +from-T1w_to-template_mode-image_desc-initial_xfm xfm anat MAT +from-T1w_to-template_mode-image_desc-rigid_xfm xfm anat MAT +from-T1w_to-template_mode-image_desc-affine_xfm xfm anat MAT +from-T1w_to-longitudinal_mode-image_desc-initial_xfm xfm anat MAT +from-T1w_to-longitudinal_mode-image_desc-rigid_xfm xfm anat MAT +from-T1w_to-longitudinal_mode-image_desc-affine_xfm xfm anat MAT +from-T1w_to-symtemplate_mode-image_desc-initial_xfm xfm anat MAT +from-T1w_to-symtemplate_mode-image_desc-rigid_xfm xfm anat MAT +from-T1w_to-symtemplate_mode-image_desc-affine_xfm xfm anat MAT +from-longitudinal_to-template_mode-image_desc-initial_xfm xfm anat MAT +from-longitudinal_to-template_mode-image_desc-rigid_xfm xfm anat MAT +from-longitudinal_to-template_mode-image_desc-affine_xfm xfm anat MAT +from-longitudinal_to-symtemplate_mode-image_desc-initial_xfm xfm anat MAT +from-longitudinal_to-symtemplate_mode-image_desc-rigid_xfm xfm anat MAT +from-longitudinal_to-symtemplate_mode-image_desc-affine_xfm xfm anat MAT +from-longitudinal_to-T1w_mode-image_desc-initial_xfm xfm anat MAT +from-longitudinal_to-T1w_mode-image_desc-rigid_xfm xfm anat MAT +from-longitudinal_to-T1w_mode-image_desc-affine_xfm xfm anat MAT +from-bold_to-EPItemplate_mode-image_desc-initial_xfm xfm func MAT +from-bold_to-EPItemplate_mode-image_desc-rigid_xfm xfm func MAT +from-bold_to-EPItemplate_mode-image_desc-affine_xfm xfm func MAT +from-bold_to-EPItemplate_mode-image_desc-flirt_xfm xfm func MAT +from-EPItemplate_to-bold_mode-image_desc-flirt_xfm xfm func MAT space-template_label-CSF_mask mask template anat NIfTI space-template_label-WM_mask mask template anat NIfTI space-template_label-GM_mask mask template anat NIfTI diff --git a/CPAC/resources/cpac_templates.csv b/CPAC/resources/cpac_templates.csv index 5c2abc9947..cf4cad758f 100644 --- a/CPAC/resources/cpac_templates.csv +++ b/CPAC/resources/cpac_templates.csv @@ -31,8 +31,8 @@ T1w-template-symmetric,"voxel_mirrored_homotopic_connectivity, symmetric_registr T1w-template-symmetric-deriv,"voxel_mirrored_homotopic_connectivity, symmetric_registration, T1w_template_symmetric_funcreg","Symmetric version of the T1w-based whole-head template, resampled to the desired functional derivative resolution","registration_workflows, functional_registration, func_registration_to_template, output_resolution, func_derivative_outputs" T1w-template-symmetric-for-resample,"voxel_mirrored_homotopic_connectivity, symmetric_registration, T1w_template_symmetric_for_resample",, template-ref-mask,"registration_workflows, anatomical_registration, registration, FSL-FNIRT, ref_mask",,"registration_workflows, anatomical_registration, resolution_for_anat" -template-ref-mask-res-2,"registration_workflows, anatomical_registration, registration, FSL-FNIRT, ref_mask_res-2",, -T1w-template-res-2,"registration_workflows, anatomical_registration, registration, FSL-FNIRT, T1w_template_res-2",, +template-ref-mask-res-2,"surface_analysis, abcd_prefreesurfer_prep, ref_mask_res-2",, +T1w-template-res-2,"surface_analysis, abcd_prefreesurfer_prep, T1w_template_res-2",, template-specification-file,"network_centrality, template_specification_file",Binary ROI mask for network centrality calculations, unet-model,"anatomical_preproc, brain_extraction, UNet, unet_model",, WM-path,"segmentation, tissue_segmentation, FSL-FAST, use_priors, WM_path",Template-space WM tissue prior, diff --git a/CPAC/resources/templates/lookup_table.py b/CPAC/resources/templates/lookup_table.py index 4314e0b4d7..d216bafdba 100644 --- a/CPAC/resources/templates/lookup_table.py +++ b/CPAC/resources/templates/lookup_table.py @@ -1,4 +1,4 @@ -# Copyright (C) 2022-2024 C-PAC Developers +# Copyright (C) 2022-2025 C-PAC Developers # This file is part of C-PAC. @@ -19,7 +19,8 @@ See `Standard template identifiers `_. """ -from os import environ, path as op +from importlib.resources import files +from os import environ from re import findall, search from typing import Optional @@ -32,7 +33,7 @@ str(row[2]) if row[2] else None, ) for row in loadtxt( - op.join(op.dirname(__file__), "BIDS_identifiers.tsv"), + str(files("CPAC").joinpath("resources/templates/BIDS_identifiers.tsv")), dtype="str", delimiter="\t", ) diff --git a/CPAC/resources/tests/test_templates.py b/CPAC/resources/tests/test_templates.py index 13a4f72745..048cbe9b1c 100644 --- a/CPAC/resources/tests/test_templates.py +++ b/CPAC/resources/tests/test_templates.py @@ -19,6 +19,7 @@ import os import pytest +import nipype.pipeline.engine as pe from CPAC.pipeline import ALL_PIPELINE_CONFIGS from CPAC.pipeline.engine import ingress_pipeconfig_paths, ResourcePool @@ -29,11 +30,11 @@ @pytest.mark.parametrize("pipeline", ALL_PIPELINE_CONFIGS) def test_packaged_path_exists(pipeline): """ - Check that all local templates are included in image at at - least one resolution. + Check that all local templates are included in image at atleast one resolution. """ - rpool = ingress_pipeconfig_paths( - Preconfiguration(pipeline), ResourcePool(), "pytest" + wf = pe.Workflow(name="test") + wf, rpool = ingress_pipeconfig_paths( + wf, Preconfiguration(pipeline), ResourcePool(), "pytest" ) for resource in rpool.rpool.values(): node = next(iter(resource.values())).get("data")[0] diff --git a/CPAC/seg_preproc/seg_preproc.py b/CPAC/seg_preproc/seg_preproc.py index f769cf14b3..69db6d60cb 100644 --- a/CPAC/seg_preproc/seg_preproc.py +++ b/CPAC/seg_preproc/seg_preproc.py @@ -1,4 +1,4 @@ -# Copyright (C) 2012-2023 C-PAC Developers +# Copyright (C) 2012-2025 C-PAC Developers # This file is part of C-PAC. @@ -35,7 +35,6 @@ from CPAC.utils.interfaces.function.seg_preproc import ( pick_tissue_from_labels_file_interface, ) -from CPAC.utils.utils import check_prov_for_regtool def process_segment_map(wf_name, use_priors, use_custom_threshold, reg_tool): @@ -536,7 +535,6 @@ def tissue_seg_fsl_fast(wf, cfg, strat_pool, pipe_num, opt=None): # triggered by 'segments' boolean input (-g or --segments) # 'probability_maps' output is a list of individual probability maps # triggered by 'probability_maps' boolean input (-p) - segment = pe.Node( interface=fsl.FAST(), name=f"segment_{pipe_num}", @@ -596,10 +594,8 @@ def tissue_seg_fsl_fast(wf, cfg, strat_pool, pipe_num, opt=None): xfm = "from-template_to-T1w_mode-image_desc-linear_xfm" if "space-longitudinal" in resource: xfm = "from-template_to-longitudinal_mode-image_desc-linear_xfm" - xfm_prov = strat_pool.get_cpac_provenance(xfm) - reg_tool = check_prov_for_regtool(xfm_prov) + reg_tool = strat_pool.reg_tool(xfm) else: - xfm_prov = None reg_tool = None xfm = None @@ -752,10 +748,7 @@ def tissue_seg_fsl_fast(wf, cfg, strat_pool, pipe_num, opt=None): outputs=["label-CSF_mask", "label-GM_mask", "label-WM_mask"], ) def tissue_seg_T1_template_based(wf, cfg, strat_pool, pipe_num, opt=None): - xfm_prov = strat_pool.get_cpac_provenance( - "from-template_to-T1w_mode-image_desc-linear_xfm" - ) - reg_tool = check_prov_for_regtool(xfm_prov) + reg_tool = strat_pool.reg_tool("from-template_to-T1w_mode-image_desc-linear_xfm") use_ants = reg_tool == "ants" csf_template2t1 = tissue_mask_template_to_t1(f"CSF_{pipe_num}", use_ants) @@ -806,10 +799,9 @@ def tissue_seg_T1_template_based(wf, cfg, strat_pool, pipe_num, opt=None): ], ) def tissue_seg_EPI_template_based(wf, cfg, strat_pool, pipe_num, opt=None): - xfm_prov = strat_pool.get_cpac_provenance( + reg_tool = strat_pool.reg_tool( "from-EPItemplate_to-bold_mode-image_desc-linear_xfm" ) - reg_tool = check_prov_for_regtool(xfm_prov) use_ants = reg_tool == "ants" csf_template2t1 = tissue_mask_template_to_t1("CSF", use_ants) diff --git a/CPAC/seg_preproc/tests/__init__.py b/CPAC/seg_preproc/tests/__init__.py new file mode 100644 index 0000000000..788d202e81 --- /dev/null +++ b/CPAC/seg_preproc/tests/__init__.py @@ -0,0 +1,17 @@ +# Copyright (C) 2025 C-PAC Developers + +# This file is part of C-PAC. + +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. + +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . +"""Tests for segmentation utilities.""" diff --git a/CPAC/seg_preproc/tests/test_utils.py b/CPAC/seg_preproc/tests/test_utils.py new file mode 100644 index 0000000000..de5521aa4c --- /dev/null +++ b/CPAC/seg_preproc/tests/test_utils.py @@ -0,0 +1,34 @@ +# Copyright (C) 2025 C-PAC Developers + +# This file is part of C-PAC. + +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. + +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . +"""Tests for segmentation utilities.""" + +import subprocess + + +def test_ants_joint_label_fusion_script() -> None: + """Test antsJointLabelFusion.sh script can run in this environment.""" + try: + subprocess.run( + ["antsJointLabelFusion.sh"], + check=True, + capture_output=True, + ) + except subprocess.CalledProcessError as e: + # There's no explicit 'help' option, but if the script can run, + # the error message does not contain the string "Error". + if "Error" in e.stderr.decode(): + raise e diff --git a/CPAC/surface/surf_preproc.py b/CPAC/surface/surf_preproc.py index 1defe4e2d1..017ce4d604 100644 --- a/CPAC/surface/surf_preproc.py +++ b/CPAC/surface/surf_preproc.py @@ -928,7 +928,7 @@ def run_surface( [ "space-template_desc-head_T1w", "space-template_desc-brain_T1w", - "space-template_desc-T1w_mask", + "space-template_desc-brain_mask", ], [ "from-T1w_to-template_mode-image_xfm", @@ -1202,7 +1202,7 @@ def surface_postproc(wf, cfg, strat_pool, pipe_num, opt=None): space_temp = [ "space-template_desc-head_T1w", "space-template_desc-brain_T1w", - "space-template_desc-T1w_mask", + "space-template_desc-brain_mask", ] atlas_xfm = [ "from-T1w_to-template_mode-image_xfm", diff --git a/CPAC/surface/tests/test_config.py b/CPAC/surface/tests/test_config.py index 046ea8fb55..97d248ed3e 100644 --- a/CPAC/surface/tests/test_config.py +++ b/CPAC/surface/tests/test_config.py @@ -1,31 +1,41 @@ +# Copyright (C) 2022-2025 C-PAC Developers + +# This file is part of C-PAC. + +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. + +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . """Tests for surface configuration.""" -import os +from pathlib import Path +from typing import cast -import pkg_resources as p import pytest import yaml from CPAC.pipeline.cpac_pipeline import run_workflow +from CPAC.resources.configs import CONFIGS_PATH from CPAC.utils.configuration import Configuration @pytest.mark.skip(reason="timing out for unrelated reasons") @pytest.mark.timeout(60) -def test_duplicate_freesurfer(tmp_path): +def test_duplicate_freesurfer(tmp_path: Path) -> None: """The pipeline should build fast if freesurfer is not self-duplicating.""" config = Configuration(yaml.safe_load("FROM: abcd-options")) - with open( - p.resource_filename( - "CPAC", - os.path.join("resources", "configs", "data_config_S3-BIDS-ABIDE.yml"), - ), - "r", - ) as data_config: + with (CONFIGS_PATH / "data_config_S3-BIDS-ABIDE.yml").open("r") as data_config: sub_dict = yaml.safe_load(data_config)[0] for directory in ["output", "working", "log", "crash_log"]: directory_key = ["pipeline_setup", f"{directory}_directory", "path"] - config[directory_key] = os.path.join( - tmp_path, config[directory_key].lstrip("/") - ) + item = cast(str, config[directory_key]) + config[directory_key] = str(tmp_path / item.lstrip("/")) run_workflow(sub_dict, config, False, test_config=True) diff --git a/CPAC/surface/tests/test_installation.py b/CPAC/surface/tests/test_installation.py index 0af0a9621a..3f53330435 100644 --- a/CPAC/surface/tests/test_installation.py +++ b/CPAC/surface/tests/test_installation.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023 C-PAC Developers +# Copyright (C) 2023-2025 C-PAC Developers # This file is part of C-PAC. @@ -17,18 +17,27 @@ """Tests for requisite surface prerequisites.""" import os +from typing import Literal import pytest from CPAC.utils.tests.test_utils import _installation_check -@pytest.mark.parametrize("executable", ["bc", "csh"]) -@pytest.mark.skipif( - "FREESURFER_HOME" not in os.environ - or not os.path.exists(os.environ["FREESURFER_HOME"]), - reason="We don't need these dependencies if we don't have FreeSurfer.", +@pytest.mark.parametrize( + "executable", + [ + "bc", + pytest.param( + "csh", + marks=pytest.mark.skipif( + "FREESURFER_HOME" not in os.environ + or not os.path.exists(os.environ["FREESURFER_HOME"]), + reason="We don't need this dependency if we don't have FreeSurfer.", + ), + ), + ], ) -def test_executable(executable): +def test_executable(executable: Literal["bc"] | Literal["csh"]) -> None: """Make sure executable is installed.""" _installation_check(executable, "--version") diff --git a/CPAC/utils/bids_utils.py b/CPAC/utils/bids_utils.py index 34e72d430e..4ed9e45e7c 100755 --- a/CPAC/utils/bids_utils.py +++ b/CPAC/utils/bids_utils.py @@ -14,10 +14,13 @@ # You should have received a copy of the GNU Lesser General Public # License along with C-PAC. If not, see . +from base64 import b64decode +from collections.abc import Iterable import json import os import re import sys +from typing import Any, Callable, Optional from warnings import warn from botocore.exceptions import BotoCoreError @@ -26,6 +29,16 @@ from CPAC.utils.monitoring import UTLOGGER +class SpecifiedBotoCoreError(BotoCoreError): + """Specified :py:class:`~botocore.exceptions.BotoCoreError`.""" + + def __init__(self, msg: str, *args, **kwargs) -> None: + """Initialize BotoCoreError with message.""" + msg = msg.format(**kwargs) + Exception.__init__(self, msg) + self.kwargs = kwargs + + def bids_decode_fname(file_path, dbg=False, raise_error=True): f_dict = {} @@ -842,7 +855,7 @@ def collect_bids_files_configs(bids_dir, aws_input_creds=""): f"Error retrieving {s3_obj.key.replace(prefix, '')}" f" ({e.message})" ) - raise BotoCoreError(msg) from e + raise SpecifiedBotoCoreError(msg) from e elif "nii" in str(s3_obj.key): file_paths.append( str(s3_obj.key).replace(prefix, "").lstrip("/") @@ -868,9 +881,15 @@ def collect_bids_files_configs(bids_dir, aws_input_creds=""): ): json.load(open(os.path.join(root, f), "r")) } ) - except UnicodeDecodeError: + except UnicodeDecodeError as unicode_decode_error: msg = f"Could not decode {os.path.join(root, f)}" - raise UnicodeDecodeError(msg) + raise UnicodeDecodeError( + unicode_decode_error.encoding, + unicode_decode_error.object, + unicode_decode_error.start, + unicode_decode_error.end, + msg, + ) if not file_paths and not config_dict: msg = ( @@ -983,15 +1002,35 @@ def insert_entity(resource, key, value): return "_".join([*new_entities[0], f"{key}-{value}", *new_entities[1], suff]) -def load_yaml_config(config_filename, aws_input_creds): +def apply_modifications( + yaml_contents: str, modifications: Optional[list[Callable[[str], str]]] +) -> str: + """Apply modification functions to YAML contents""" + if modifications: + for modification in modifications: + yaml_contents = modification(yaml_contents) + return yaml_contents + + +def load_yaml_config( + config_filename: str, + aws_input_creds, + modifications: Optional[list[Callable[[str], str]]] = None, +) -> dict | list | str: + """Load a YAML config file, possibly from AWS, with modifications applied. + + `modifications` should be a list of functions that take a single string argument (the loaded YAML contents) and return a single string argument (the modified YAML contents). + """ if config_filename.lower().startswith("data:"): try: - header, encoded = config_filename.split(",", 1) - config_content = b64decode(encoded) + _header, encoded = config_filename.split(",", 1) + config_content = apply_modifications( + b64decode(encoded).decode("utf-8"), modifications + ) return yaml.safe_load(config_content) - except: + except Exception: msg = f"Error! Could not find load config from data URI {config_filename}" - raise BotoCoreError(msg) + raise SpecifiedBotoCoreError(msg=msg) if config_filename.lower().startswith("s3://"): # s3 paths begin with s3://bucket/ @@ -1013,7 +1052,8 @@ def load_yaml_config(config_filename, aws_input_creds): config_filename = os.path.realpath(config_filename) try: - return yaml.safe_load(open(config_filename, "r")) + with open(config_filename, "r") as _f: + return yaml.safe_load(apply_modifications(_f.read(), modifications)) except IOError: msg = f"Error! Could not find config file {config_filename}" raise FileNotFoundError(msg) @@ -1110,6 +1150,25 @@ def create_cpac_data_config( return sub_list +def _check_value_type( + sub_list: list[dict[str, Any]], + keys: list[str] = ["subject_id", "unique_id"], + value_type: type = int, + any_or_all: Callable[[Iterable], bool] = any, +) -> bool: + """Check if any or all of a key in a sub_list is of a given type.""" + return any_or_all( + isinstance(sub.get(key), value_type) for key in keys for sub in sub_list + ) + + +def coerce_data_config_strings(contents: str) -> str: + """Coerge `subject_id` and `unique_id` to be strings.""" + for key in ["subject_id: ", "unique_id: "]: + contents = re.sub(f"{key}(?!!!)", f"{key}!!str ", contents) + return contents.replace(": !!str !!", ": !!") + + def load_cpac_data_config(data_config_file, participant_labels, aws_input_creds): """ Loads the file as a check to make sure it is available and readable. @@ -1127,7 +1186,9 @@ def load_cpac_data_config(data_config_file, participant_labels, aws_input_creds) ------- list """ - sub_list = load_yaml_config(data_config_file, aws_input_creds) + sub_list: list[dict[str, str]] = load_yaml_config( + data_config_file, aws_input_creds, modifications=[coerce_data_config_strings] + ) if participant_labels: sub_list = [ diff --git a/CPAC/utils/build_data_config.py b/CPAC/utils/build_data_config.py index 8be6c6b234..6d1e2d9f0f 100644 --- a/CPAC/utils/build_data_config.py +++ b/CPAC/utils/build_data_config.py @@ -16,14 +16,12 @@ # License along with C-PAC. If not, see . """Build a C-PAC data configuration.""" -from logging import basicConfig, INFO from pathlib import Path from typing import Any from CPAC.utils.monitoring.custom_logging import getLogger logger = getLogger("CPAC.utils.data-config") -basicConfig(format="%(message)s", level=INFO) def _cannot_write(file_name: Path | str) -> None: @@ -1828,8 +1826,7 @@ def util_copy_template(template_type=None): import os import shutil - import pkg_resources as p - + from CPAC.resources.configs import CONFIGS_PATH from CPAC.utils.configuration import preconfig_yaml template_type = "data_settings" if not template_type else template_type @@ -1837,10 +1834,7 @@ def util_copy_template(template_type=None): settings_template = ( preconfig_yaml("default") if (template_type == "pipeline_config") - else p.resource_filename( - "CPAC", - os.path.join("resources", "configs", f"{template_type}_template.yml"), - ) + else str(CONFIGS_PATH / f"{template_type}_template.yml") ) settings_file = os.path.join(os.getcwd(), f"{template_type}.yml") diff --git a/CPAC/utils/configuration/configuration.py b/CPAC/utils/configuration/configuration.py index 8444cce105..0d94752487 100644 --- a/CPAC/utils/configuration/configuration.py +++ b/CPAC/utils/configuration/configuration.py @@ -1,4 +1,4 @@ -# Copyright (C) 2022-2024 C-PAC Developers +# Copyright (C) 2022-2025 C-PAC Developers # This file is part of C-PAC. @@ -22,9 +22,9 @@ from warnings import warn from click import BadParameter -import pkg_resources as p import yaml +from CPAC.resources.configs import CONFIGS_PATH from .diff import dct_diff CONFIG_KEY_TYPE = str | list[str] @@ -50,7 +50,10 @@ class Configuration: will form the base of the Configuration object with the values in the given dictionary overriding matching keys in the base at any depth. If no ``FROM`` key is included, the base Configuration is - the default Configuration. + the blank preconfiguration. + + .. versionchanged:: 1.8.5 + From version 1.8.0 to version 1.8.5, unspecified keys were based on the default configuration rather than the blank preconfiguration. ``FROM`` accepts either the name of a preconfigured pipleine or a path to a YAML file. @@ -734,10 +737,7 @@ def preconfig_yaml(preconfig_name="default", load=False): if load: with open(preconfig_yaml(preconfig_name), "r", encoding="utf-8") as _f: return yaml.safe_load(_f) - return p.resource_filename( - "CPAC", - os.path.join("resources", "configs", f"pipeline_config_{preconfig_name}.yml"), - ) + return str(CONFIGS_PATH / f"pipeline_config_{preconfig_name}.yml") class Preconfiguration(Configuration): diff --git a/CPAC/utils/create_fsl_flame_preset.py b/CPAC/utils/create_fsl_flame_preset.py index 856c10a3b4..848fe5e9fe 100644 --- a/CPAC/utils/create_fsl_flame_preset.py +++ b/CPAC/utils/create_fsl_flame_preset.py @@ -1,4 +1,4 @@ -# Copyright (C) 2018-2024 C-PAC Developers +# Copyright (C) 2018-2025 C-PAC Developers # This file is part of C-PAC. @@ -1092,20 +1092,6 @@ def run( import os - import pandas as pd - import pkg_resources as p - - # make life easy - keys_csv = p.resource_filename("CPAC", "resources/cpac_outputs.csv") - try: - pd.read_csv(keys_csv) - except Exception as e: - err = ( - "\n[!] Could not access or read the cpac_outputs.csv " - f"resource file:\n{keys_csv}\n\nError details {e}\n" - ) - raise Exception(err) - if derivative_list == "all": derivative_list = [ "alff", diff --git a/CPAC/utils/datasource.py b/CPAC/utils/datasource.py index 008e674c2d..89416edd15 100644 --- a/CPAC/utils/datasource.py +++ b/CPAC/utils/datasource.py @@ -1,4 +1,4 @@ -# Copyright (C) 2012-2024 C-PAC Developers +# Copyright (C) 2012-2025 C-PAC Developers # This file is part of C-PAC. @@ -20,6 +20,7 @@ import json from pathlib import Path import re +from typing import Any, Optional from voluptuous import RequiredFieldInvalid from nipype.interfaces import utility as util @@ -376,6 +377,8 @@ def create_fmap_datasource(fmap_dct, wf_name="fmap_datasource"): def get_fmap_phasediff_metadata(data_config_scan_params): """Return the scan parameters for a field map phasediff scan.""" + from CPAC.utils.utils import get_fmap_type + if ( not isinstance(data_config_scan_params, dict) and ".json" in data_config_scan_params @@ -397,7 +400,12 @@ def get_fmap_phasediff_metadata(data_config_scan_params): dwell_time = data_config_scan_params.get("DwellTime") pe_direction = data_config_scan_params.get("PhaseEncodingDirection") total_readout = data_config_scan_params.get("TotalReadoutTime") + if "EffectiveEchoSpacing" in data_config_scan_params: + effective_echo_spacing = data_config_scan_params.get("EffectiveEchoSpacing") + else: + effective_echo_spacing = None + fmap_type = get_fmap_type(data_config_scan_params) return ( dwell_time, pe_direction, @@ -405,6 +413,8 @@ def get_fmap_phasediff_metadata(data_config_scan_params): echo_time, echo_time_one, echo_time_two, + effective_echo_spacing, + fmap_type, ) @@ -463,12 +473,12 @@ def gather_echo_times(echotime_1, echotime_2, echotime_3=None, echotime_4=None): def match_epi_fmaps( - bold_pedir, - epi_fmap_one, - epi_fmap_params_one, - epi_fmap_two=None, - epi_fmap_params_two=None, -): + bold_pedir: str, + epi_fmap_one: str, + epi_fmap_params_one: dict[str, Any], + epi_fmap_two: Optional[str] = None, + epi_fmap_params_two: Optional[dict[str, Any]] = None, +) -> tuple[str, str]: """Match EPI field maps to the BOLD scan. Parse the field map files in the data configuration and determine which @@ -504,13 +514,41 @@ def match_epi_fmaps( with open(scan_params, "r") as f: scan_params = json.load(f) if "PhaseEncodingDirection" in scan_params: - epi_pedir = scan_params["PhaseEncodingDirection"] + epi_pedir: str | bytes = scan_params["PhaseEncodingDirection"] + if isinstance(epi_pedir, bytes): + epi_pedir = epi_pedir.decode("utf-8") if epi_pedir == bold_pedir: same_pe_epi = epi_scan elif epi_pedir[0] == bold_pedir[0]: opposite_pe_epi = epi_scan - return (opposite_pe_epi, same_pe_epi) + if same_pe_epi is None: + msg = f"Same phase encoding EPI: {bold_pedir}" + raise FileNotFoundError(msg) + if opposite_pe_epi is None: + msg = f"Opposite phase encoding EPI: {bold_pedir}" + raise FileNotFoundError(msg) + + return opposite_pe_epi, same_pe_epi + + +def match_epi_fmaps_function_node(name: str = "match_epi_fmaps"): + """Return a Function node for `~CPAC.utils.datasource.match_epi_fmaps`.""" + return pe.Node( + Function( + input_names=[ + "bold_pedir", + "epi_fmap_one", + "epi_fmap_params_one", + "epi_fmap_two", + "epi_fmap_params_two", + ], + output_names=["opposite_pe_epi", "same_pe_epi"], + function=match_epi_fmaps, + as_module=True, + ), + name=name, + ) def ingress_func_metadata( @@ -524,18 +562,104 @@ def ingress_func_metadata( num_strat=None, ): """Ingress metadata for functional scans.""" + from CPAC.utils.utils import get_fmap_build_info, get_fmap_metadata_at_build_time + name_suffix = "" for suffix_part in (unique_id, num_strat): if suffix_part is not None: name_suffix += f"_{suffix_part}" - # Grab field maps + + scan_params = pe.Node( + Function( + input_names=[ + "data_config_scan_params", + "subject_id", + "scan", + "pipeconfig_tr", + "pipeconfig_tpattern", + "pipeconfig_start_indx", + "pipeconfig_stop_indx", + ], + output_names=[ + "tr", + "tpattern", + "template", + "ref_slice", + "start_indx", + "stop_indx", + "pe_direction", + "effective_echo_spacing", + ], + function=get_scan_params, + ), + name=f"bold_scan_params_{subject_id}{name_suffix}", + ) + scan_params.inputs.subject_id = subject_id + scan_params.inputs.set( + pipeconfig_start_indx=cfg.functional_preproc["truncation"]["start_tr"], + pipeconfig_stop_indx=cfg.functional_preproc["truncation"]["stop_tr"], + ) + + node, out = rpool.get("scan")["['scan:func_ingress']"]["data"] + wf.connect(node, out, scan_params, "scan") + + # Workaround for extracting metadata with ingress + if rpool.check_rpool("derivatives-dir"): + selectrest_json = pe.Node( + function.Function( + input_names=["scan", "rest_dict", "resource"], + output_names=["file_path"], + function=get_rest, + as_module=True, + ), + name="selectrest_json", + ) + selectrest_json.inputs.rest_dict = sub_dict + selectrest_json.inputs.resource = "scan_parameters" + wf.connect(node, out, selectrest_json, "scan") + wf.connect(selectrest_json, "file_path", scan_params, "data_config_scan_params") + else: + # wire in the scan parameter workflow + node, out = rpool.get("scan-params")["['scan-params:scan_params_ingress']"][ + "data" + ] + wf.connect(node, out, scan_params, "data_config_scan_params") + + rpool.set_data("TR", scan_params, "tr", {}, "", "func_metadata_ingress") + rpool.set_data("tpattern", scan_params, "tpattern", {}, "", "func_metadata_ingress") + rpool.set_data("template", scan_params, "template", {}, "", "func_metadata_ingress") + rpool.set_data( + "start-tr", scan_params, "start_indx", {}, "", "func_metadata_ingress" + ) + rpool.set_data("stop-tr", scan_params, "stop_indx", {}, "", "func_metadata_ingress") + rpool.set_data( + "pe-direction", scan_params, "pe_direction", {}, "", "func_metadata_ingress" + ) + rpool.set_data( + "effectiveEchoSpacing", + scan_params, + "effective_echo_spacing", + {}, + "", + "func_metadata_ingress", + ) + diff = False blip = False fmap_rp_list = [] fmap_TE_list = [] + if "fmap" in sub_dict: second = False for orig_key in sub_dict["fmap"]: + fmap_metadata = get_fmap_metadata_at_build_time( + sub_dict, + orig_key, + input_creds_path, + cfg.pipeline_setup["working_directory"]["path"], + ) + build_info = get_fmap_build_info(fmap_metadata) + gather_fmap = create_fmap_datasource( sub_dict["fmap"], f"fmap_gather_{orig_key}_{subject_id}" ) @@ -565,7 +689,10 @@ def ingress_func_metadata( fmap_rp_list.append(key) - get_fmap_metadata_imports = ["import json"] + get_fmap_metadata_imports = [ + "import json", + "from CPAC.utils.utils import get_fmap_type", + ] get_fmap_metadata = pe.Node( Function( input_names=["data_config_scan_params"], @@ -576,6 +703,8 @@ def ingress_func_metadata( "echo_time", "echo_time_one", "echo_time_two", + "effective_echo_spacing", + "fmap_type", ], function=get_fmap_phasediff_metadata, imports=get_fmap_metadata_imports, @@ -590,14 +719,16 @@ def ingress_func_metadata( "data_config_scan_params", ) - if "phase" in key: - # leave it open to all three options, in case there is a - # phasediff image with either a single EchoTime field (which - # usually matches one of the magnitude EchoTimes), OR - # a phasediff with an EchoTime1 and EchoTime2 + rpool.set_data( + f"{key}-fmap-type", + get_fmap_metadata, + "fmap_type", + {}, + "", + "fmap_type_ingress", + ) - # at least one of these rpool keys will have a None value, - # which will be sorted out in gather_echo_times below + if build_info["needs_echo_times"]: rpool.set_data( f"{key}-TE", get_fmap_metadata, @@ -628,16 +759,11 @@ def ingress_func_metadata( ) fmap_TE_list.append(f"{key}-TE2") - elif "magnitude" in key: - rpool.set_data( - f"{key}-TE", - get_fmap_metadata, - "echo_time", - {}, - "", - "fmap_TE_ingress", - ) - fmap_TE_list.append(f"{key}-TE") + if build_info["needs_phasediff_processing"]: + diff = True + + if build_info["is_epi"] or re.match("epi_[AP]{2}", orig_key): + blip = True rpool.set_data( f"{key}-dwell", @@ -664,23 +790,8 @@ def ingress_func_metadata( "fmap_readout_ingress", ) - if "phase" in key or "mag" in key: - diff = True - - if re.match("epi_[AP]{2}", orig_key): - blip = True - - if diff: - calc_delta_ratio = pe.Node( - Function( - input_names=["effective_echo_spacing", "echo_times"], - output_names=["deltaTE", "ees_asym_ratio"], - function=calc_delta_te_and_asym_ratio, - imports=["from typing import Optional"], - ), - name=f"diff_distcor_calc_delta{name_suffix}", - ) - + # Set up phasediff processing workflow if needed + if diff and fmap_TE_list: gather_echoes = pe.Node( Function( input_names=[ @@ -695,7 +806,8 @@ def ingress_func_metadata( name="fugue_gather_echo_times", ) - for idx, fmap_file in enumerate(fmap_TE_list, start=1): + # Connect available echo times + for idx, fmap_file in enumerate(fmap_TE_list[:4], start=1): try: node, out_file = rpool.get(fmap_file)[ f"['{fmap_file}:fmap_TE_ingress']" @@ -704,103 +816,34 @@ def ingress_func_metadata( except KeyError: pass - wf.connect(gather_echoes, "echotime_list", calc_delta_ratio, "echo_times") - - # Add in nodes to get parameters from configuration file - # a node which checks if scan_parameters are present for each scan - scan_params = pe.Node( - Function( - input_names=[ - "data_config_scan_params", - "subject_id", - "scan", - "pipeconfig_tr", - "pipeconfig_tpattern", - "pipeconfig_start_indx", - "pipeconfig_stop_indx", - ], - output_names=[ - "tr", - "tpattern", - "template", - "ref_slice", - "start_indx", - "stop_indx", - "pe_direction", - "effective_echo_spacing", - ], - function=get_scan_params, - ), - name=f"bold_scan_params_{subject_id}{name_suffix}", - ) - scan_params.inputs.subject_id = subject_id - scan_params.inputs.set( - pipeconfig_start_indx=cfg.functional_preproc["truncation"]["start_tr"], - pipeconfig_stop_indx=cfg.functional_preproc["truncation"]["stop_tr"], - ) - - node, out = rpool.get("scan")["['scan:func_ingress']"]["data"] - wf.connect(node, out, scan_params, "scan") - - # Workaround for extracting metadata with ingress - if rpool.check_rpool("derivatives-dir"): - selectrest_json = pe.Node( - function.Function( - input_names=["scan", "rest_dict", "resource"], - output_names=["file_path"], - function=get_rest, - as_module=True, - ), - name="selectrest_json", - ) - selectrest_json.inputs.rest_dict = sub_dict - selectrest_json.inputs.resource = "scan_parameters" - wf.connect(node, out, selectrest_json, "scan") - wf.connect(selectrest_json, "file_path", scan_params, "data_config_scan_params") + calc_delta_ratio = pe.Node( + Function( + input_names=["effective_echo_spacing", "echo_times"], + output_names=["deltaTE", "ees_asym_ratio"], + function=calc_delta_te_and_asym_ratio, + imports=["from typing import Optional"], + ), + name=f"diff_distcor_calc_delta{name_suffix}", + ) - else: - # wire in the scan parameter workflow - node, out = rpool.get("scan-params")["['scan-params:scan_params_ingress']"][ - "data" - ] - wf.connect(node, out, scan_params, "data_config_scan_params") + wf.connect(gather_echoes, "echotime_list", calc_delta_ratio, "echo_times") - rpool.set_data("TR", scan_params, "tr", {}, "", "func_metadata_ingress") - rpool.set_data("tpattern", scan_params, "tpattern", {}, "", "func_metadata_ingress") - rpool.set_data("template", scan_params, "template", {}, "", "func_metadata_ingress") - rpool.set_data( - "start-tr", scan_params, "start_indx", {}, "", "func_metadata_ingress" - ) - rpool.set_data("stop-tr", scan_params, "stop_indx", {}, "", "func_metadata_ingress") - rpool.set_data( - "pe-direction", scan_params, "pe_direction", {}, "", "func_metadata_ingress" - ) + node, out_file = rpool.get("effectiveEchoSpacing")[ + "['effectiveEchoSpacing:func_metadata_ingress']" + ]["data"] + wf.connect(node, out_file, calc_delta_ratio, "effective_echo_spacing") - if diff: - # Connect EffectiveEchoSpacing from functional metadata - rpool.set_data( - "effectiveEchoSpacing", - scan_params, - "effective_echo_spacing", - {}, - "", - "func_metadata_ingress", - ) - node, out_file = rpool.get("effectiveEchoSpacing")[ - "['effectiveEchoSpacing:func_metadata_ingress']" - ]["data"] - wf.connect(node, out_file, calc_delta_ratio, "effective_echo_spacing") - rpool.set_data( - "deltaTE", calc_delta_ratio, "deltaTE", {}, "", "deltaTE_ingress" - ) - rpool.set_data( - "ees-asym-ratio", - calc_delta_ratio, - "ees_asym_ratio", - {}, - "", - "ees_asym_ratio_ingress", - ) + rpool.set_data( + "deltaTE", calc_delta_ratio, "deltaTE", {}, "", "deltaTE_ingress" + ) + rpool.set_data( + "ees-asym-ratio", + calc_delta_ratio, + "ees_asym_ratio", + {}, + "", + "ees_asym_ratio_ingress", + ) return wf, rpool, diff, blip, fmap_rp_list @@ -885,12 +928,15 @@ def check_for_s3( ): """Check if passed-in file is on S3.""" # Import packages + from importlib.resources import files import os import botocore.exceptions import nibabel as nib from indi_aws import fetch_creds + from CPAC.resources import templates + # Init variables s3_str = "s3://" if creds_path: @@ -974,12 +1020,9 @@ def check_for_s3( if not os.path.exists(local_path): # alert users to 2020-07-20 Neuroparc atlas update (v0 to v1) ndmg_atlases = {} - with open( - os.path.join( - os.path.dirname(os.path.dirname(__file__)), - "resources/templates/ndmg_atlases.csv", - ) - ) as ndmg_atlases_file: + with ( + files(templates).joinpath("ndmg_atlases.csv").open("r") as ndmg_atlases_file + ): ndmg_atlases["v0"], ndmg_atlases["v1"] = zip( *[ ( @@ -1156,7 +1199,7 @@ def res_string_to_tuple(resolution): return (float(resolution.replace("mm", "")),) * 3 -def resolve_resolution(resolution, template, template_name, tag=None): +def resolve_resolution(orientation, resolution, template, template_name, tag=None): """Resample a template to a given resolution.""" from nipype.interfaces import afni @@ -1203,6 +1246,7 @@ def resolve_resolution(resolution, template, template_name, tag=None): resample.inputs.resample_mode = "Cu" resample.inputs.in_file = local_path resample.base_dir = "." + resample.inputs.orientation = orientation resampled_template = resample.run() local_path = resampled_template.outputs.out_file diff --git a/CPAC/utils/interfaces/afni.py b/CPAC/utils/interfaces/afni.py index af7f4e56b2..20f2c8ae57 100644 --- a/CPAC/utils/interfaces/afni.py +++ b/CPAC/utils/interfaces/afni.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023 C-PAC Developers +# Copyright (C) 2023-2025 C-PAC Developers # This file is part of C-PAC. @@ -31,8 +31,6 @@ _major, _minor, _patch = [int(part) for part in AFNI_SEMVER.split(".")] AFNI_SEMVER = str(semver.Version.parse(f"{_major}.{_minor}.{_patch}")) del _major, _minor, _patch -AFNI_GTE_21_1_1 = semver.compare(AFNI_SEMVER, "21.1.1") >= 0 -"""AFNI version >= 21.1.1?""" class ECMInputSpec(_ECMInputSpec): @@ -51,4 +49,4 @@ class ECM(_ECM): input_spec = ECMInputSpec -__all__ = ["AFNI_GTE_21_1_1", "ECM"] +__all__ = ["ECM"] diff --git a/CPAC/utils/interfaces/conftest.py b/CPAC/utils/interfaces/conftest.py index bcf92c7dfc..bff7d64abe 100644 --- a/CPAC/utils/interfaces/conftest.py +++ b/CPAC/utils/interfaces/conftest.py @@ -35,18 +35,19 @@ """ from contextlib import contextmanager +from importlib.resources import as_file, files from os import chdir, getcwd from pathlib import Path from shutil import copytree, rmtree from tempfile import mkdtemp from pytest import fixture -import nipype -NIPYPE_DATADIR = Path(nipype.__file__).parent / "testing/data" -TEMP_FOLDER = Path(mkdtemp()) -DATA_DIR = TEMP_FOLDER / "data" -copytree(NIPYPE_DATADIR, DATA_DIR, symlinks=True) +with as_file(files("nipype").joinpath("testing/data")) as data_path: + NIPYPE_DATADIR = data_path + TEMP_FOLDER = Path(mkdtemp()) + DATA_DIR = TEMP_FOLDER / "data" + copytree(NIPYPE_DATADIR, DATA_DIR, symlinks=True) @contextmanager diff --git a/CPAC/utils/interfaces/function/function.py b/CPAC/utils/interfaces/function/function.py index 34d01373d5..2e8c764242 100644 --- a/CPAC/utils/interfaces/function/function.py +++ b/CPAC/utils/interfaces/function/function.py @@ -5,6 +5,7 @@ # * Adds `as_module` argument and property # * Adds `sig_imports` decorator # * Automatically imports global Nipype loggers in function nodes +# * Specify type of `output_names` as `str | list[str]` instead of `str` # ORIGINAL WORK'S ATTRIBUTION NOTICE: # Copyright (c) 2009-2016, Nipype developers @@ -23,7 +24,7 @@ # Prior to release 0.12, Nipype was licensed under a BSD license. -# Modifications Copyright (C) 2018-2024 C-PAC Developers +# Modifications Copyright (C) 2018-2025 C-PAC Developers # This file is part of C-PAC. @@ -157,7 +158,7 @@ class Function(NipypeFunction): def __init__( self, input_names=None, - output_names="out", + output_names: str | list[str] = "out", function=None, imports=None, as_module=False, diff --git a/CPAC/utils/interfaces/netcorr.py b/CPAC/utils/interfaces/netcorr.py index aee9a4d13d..6af44a15ab 100644 --- a/CPAC/utils/interfaces/netcorr.py +++ b/CPAC/utils/interfaces/netcorr.py @@ -19,6 +19,61 @@ class NetCorr(NipypeNetCorr): # noqa: D101 input_spec = NetCorrInputSpec + def _list_outputs(self): + """``nipype.interfaces.afni.preprocess.NetCorr._list_outputs`` with a bugfix. + + Notes + ----- + This method can be removed once nipy/nipype#3697 is merged and a release + including that PR is included in the C-PAC image. + """ + # STATEMENT OF CHANGES: + # This function is derived from sources licensed under the Apache-2.0 terms, + # and this function has been changed. + + # CHANGES: + # * Includes changes from https://github.com/nipy/nipype/pull/3697 prior to all commits between https://github.com/nipy/nipype/tree/1.8.6 and that PR being merged and released. + + # ORIGINAL WORK'S ATTRIBUTION NOTICE: + # Copyright (c) 2009-2016, Nipype developers + + # Licensed under the Apache License, Version 2.0 (the "License"); + # you may not use this file except in compliance with the License. + # You may obtain a copy of the License at + + # http://www.apache.org/licenses/LICENSE-2.0 + + # Unless required by applicable law or agreed to in writing, software + # distributed under the License is distributed on an "AS IS" BASIS, + # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + # See the License for the specific language governing permissions and + # limitations under the License. + + # Prior to release 0.12, Nipype was licensed under a BSD license. + + # Modifications copyright (C) 2024 C-PAC Developers + import glob + import os + + from nipype.interfaces.base.traits_extension import isdefined + + outputs = self.output_spec().get() + + if not isdefined(self.inputs.out_file): + prefix = self._gen_fname(self.inputs.in_file, suffix="_netcorr") + else: + prefix = self.inputs.out_file + + # All outputs should be in the same directory as the prefix + odir = os.path.dirname(os.path.abspath(prefix)) + outputs["out_corr_matrix"] = glob.glob(os.path.join(odir, "*.netcc"))[0] + + if self.inputs.ts_wb_corr or self.inputs.ts_wb_Z: + corrdir = os.path.join(odir, prefix + "_000_INDIV") + outputs["out_corr_maps"] = glob.glob(os.path.join(corrdir, "*.nii.gz")) + + return outputs + NetCorr.__doc__ = f"""{NipypeNetCorr.__doc__} `CPAC.utils.interfaces.netcorr.NetCorr` adds an additional optional input, `automask_off` diff --git a/CPAC/utils/io.py b/CPAC/utils/io.py new file mode 100644 index 0000000000..12d7d7f5d1 --- /dev/null +++ b/CPAC/utils/io.py @@ -0,0 +1,37 @@ +# Copyright (C) 2012-2024 C-PAC Developers + +# This file is part of C-PAC. + +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. + +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . +"""Utilities for inputs and outputs.""" + +from pathlib import Path + +from yaml import safe_load, YAMLError + + +def load_yaml( + path: Path | str, desc: str = "YAML file", encoding="utf8" +) -> dict | list | str: + """Try to load a YAML file to a Python object.""" + path = Path(path).absolute() + try: + with path.open("r", encoding=encoding) as _yaml: + result = safe_load(_yaml) + except FileNotFoundError as error: + raise error + except Exception as error: + msg = f"{desc} is not in proper YAML format. Please check {path}" + raise YAMLError(msg) from error + return result diff --git a/CPAC/utils/monitoring/custom_logging.py b/CPAC/utils/monitoring/custom_logging.py index abd6b63438..3d8d1b842a 100644 --- a/CPAC/utils/monitoring/custom_logging.py +++ b/CPAC/utils/monitoring/custom_logging.py @@ -21,6 +21,7 @@ import subprocess from sys import exc_info as sys_exc_info from traceback import print_exception +from typing import Optional, Sequence from nipype import logging as nipype_logging @@ -59,7 +60,14 @@ def getLogger(name): # pylint: disable=invalid-name if name in MOCK_LOGGERS: return MOCK_LOGGERS[name] logger = nipype_logging.getLogger(name) - return logging.getLogger(name) if logger is None else logger + if logger is None: + logger = logging.getLogger(name) + if not logger.handlers: + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter("%(message)s")) + logger.setLevel(logging.INFO) + logger.addHandler(handler) + return logger # Nipype built-in loggers @@ -171,7 +179,9 @@ def _log(message, *items, exc_info=False): logging, level.upper(), logging.NOTSET ): with open( - self.handlers[0].baseFilename, "a", encoding="utf-8" + MockLogger._get_first_file_handler(self.handlers).baseFilename, + "a", + encoding="utf-8", ) as log_file: if exc_info and isinstance(message, Exception): value, traceback = sys_exc_info()[1:] @@ -190,6 +200,16 @@ def delete(self): """Delete the mock logger from memory.""" del MOCK_LOGGERS[self.name] + @staticmethod + def _get_first_file_handler( + handlers: Sequence[logging.Handler | MockHandler], + ) -> Optional[logging.FileHandler | MockHandler]: + """Given a list of Handlers, return the first FileHandler found or return None.""" + for handler in handlers: + if isinstance(handler, (logging.FileHandler, MockHandler)): + return handler + return None + def _lazy_sub(message, *items): """Given lazy-logging syntax, return string with substitutions. @@ -252,12 +272,12 @@ def set_up_logger( Examples -------- >>> lg = set_up_logger('test') - >>> lg.handlers[0].baseFilename.split('/')[-1] + >>> MockLogger._get_first_file_handler(lg.handlers).baseFilename.split('/')[-1] 'test.log' >>> lg.level 0 >>> lg = set_up_logger('second_test', 'specific_filename.custom', 'debug') - >>> lg.handlers[0].baseFilename.split('/')[-1] + >>> MockLogger._get_first_file_handler(lg.handlers).baseFilename.split('/')[-1] 'specific_filename.custom' >>> lg.level 10 diff --git a/CPAC/utils/monitoring/draw_gantt_chart.py b/CPAC/utils/monitoring/draw_gantt_chart.py index 089e9fdd39..2fc6dce651 100644 --- a/CPAC/utils/monitoring/draw_gantt_chart.py +++ b/CPAC/utils/monitoring/draw_gantt_chart.py @@ -23,7 +23,7 @@ # Prior to release 0.12, Nipype was licensed under a BSD license. -# Modifications Copyright (C) 2021-2023 C-PAC Developers +# Modifications Copyright (C) 2021-2025 C-PAC Developers # This file is part of C-PAC. @@ -39,19 +39,20 @@ # You should have received a copy of the GNU Lesser General Public # License along with C-PAC. If not, see . -"""Module to draw an html gantt chart from logfile produced by -``CPAC.utils.monitoring.log_nodes_cb()``. +"""Module to draw an html gantt chart from logfile produced by `~CPAC.utils.monitoring.log_nodes_cb`. See https://nipype.readthedocs.io/en/latest/api/generated/nipype.utils.draw_gantt_chart.html """ from collections import OrderedDict -from datetime import datetime +from datetime import datetime, timedelta import random from warnings import warn from nipype.utils.draw_gantt_chart import draw_lines, draw_resource_bar, log_to_dict +from CPAC.utils.monitoring.monitoring import _NoTime, DatetimeWithSafeNone + def create_event_dict(start_time, nodes_list): """ @@ -401,34 +402,39 @@ def generate_gantt_chart( return for node in nodes_list: - if "duration" not in node: - node["duration"] = (node["finish"] - node["start"]).total_seconds() + if "duration" not in node and (node["start"] and node["finish"]): + _duration = node["finish"] - node["start"] + assert isinstance(_duration, timedelta) + node["duration"] = _duration.total_seconds() # Create the header of the report with useful information start_node = nodes_list[0] last_node = nodes_list[-1] - duration = (last_node["finish"] - start_node["start"]).total_seconds() + start = DatetimeWithSafeNone(start_node["start"]) + finish = DatetimeWithSafeNone(last_node["finish"]) + if isinstance(start, _NoTime) or isinstance(finish, _NoTime): + return + start, finish = DatetimeWithSafeNone.sync_tz(start, finish) + try: + duration = (finish - start).total_seconds() + except TypeError: + # no duration + return # Get events based dictionary of node run stats - events = create_event_dict(start_node["start"], nodes_list) + events = create_event_dict(start, nodes_list) # Summary strings of workflow at top - html_string += ( - "

Start: " + start_node["start"].strftime("%Y-%m-%d %H:%M:%S") + "

" - ) - html_string += ( - "

Finish: " + last_node["finish"].strftime("%Y-%m-%d %H:%M:%S") + "

" - ) + html_string += "

Start: " + start.strftime("%Y-%m-%d %H:%M:%S") + "

" + html_string += "

Finish: " + finish.strftime("%Y-%m-%d %H:%M:%S") + "

" html_string += "

Duration: " + f"{duration / 60:.2f}" + " minutes

" html_string += "

Nodes: " + str(len(nodes_list)) + "

" html_string += "

Cores: " + str(cores) + "

" html_string += close_header # Draw nipype nodes Gantt chart and runtimes - html_string += draw_lines( - start_node["start"], duration, minute_scale, space_between_minutes - ) + html_string += draw_lines(start, duration, minute_scale, space_between_minutes) html_string += draw_nodes( - start_node["start"], + start, nodes_list, cores, minute_scale, @@ -442,8 +448,8 @@ def generate_gantt_chart( # Plot gantt chart resource_offset = 120 + 30 * cores html_string += draw_resource_bar( - start_node["start"], - last_node["finish"], + start, + finish, estimated_mem_ts, space_between_minutes, minute_scale, @@ -452,8 +458,8 @@ def generate_gantt_chart( "Memory", ) html_string += draw_resource_bar( - start_node["start"], - last_node["finish"], + start, + finish, runtime_mem_ts, space_between_minutes, minute_scale, @@ -467,8 +473,8 @@ def generate_gantt_chart( runtime_threads_ts = calculate_resource_timeseries(events, "runtime_threads") # Plot gantt chart html_string += draw_resource_bar( - start_node["start"], - last_node["finish"], + start, + finish, estimated_threads_ts, space_between_minutes, minute_scale, @@ -477,8 +483,8 @@ def generate_gantt_chart( "Threads", ) html_string += draw_resource_bar( - start_node["start"], - last_node["finish"], + start, + finish, runtime_threads_ts, space_between_minutes, minute_scale, @@ -629,7 +635,7 @@ def _timing(nodes_list): for node in nodes_list if "start" in node and "finish" in node ] - except ValueError: + except (TypeError, ValueError): # Drop any problematic nodes new_node_list = [] for node in nodes_list: @@ -656,12 +662,14 @@ def _timing_timestamp(node): msg = "No logged nodes have timing information." raise ProcessLookupError(msg) return { - k: ( + k: DatetimeWithSafeNone( datetime.strptime(v, "%Y-%m-%dT%H:%M:%S.%f") if "." in v else datetime.fromisoformat(v) ) if (k in {"start", "finish"} and isinstance(v, str)) + else DatetimeWithSafeNone(v) + if k in {"start", "finish"} else v for k, v in node.items() } diff --git a/CPAC/utils/monitoring/monitoring.py b/CPAC/utils/monitoring/monitoring.py index 950f419b95..8d715b82b8 100644 --- a/CPAC/utils/monitoring/monitoring.py +++ b/CPAC/utils/monitoring/monitoring.py @@ -1,9 +1,31 @@ +# Copyright (C) 2018-2025 C-PAC Developers + +# This file is part of C-PAC. + +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. + +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . +"""Monitoring utilities for C-PAC.""" + +from datetime import datetime, timedelta, timezone import glob import json import math import os import socketserver +import struct import threading +from typing import Any, Optional, overload, TypeAlias +from zoneinfo import available_timezones, ZoneInfo import networkx as nx from traits.trait_base import Undefined @@ -13,8 +35,260 @@ from .custom_logging import getLogger -# Log initial information from all the nodes +def _safe_none_diff( + self: "DatetimeWithSafeNone | _NoTime", other: "DatetimeWithSafeNone | _NoTime" +) -> datetime | timedelta: + """Subtract between a datetime or timedelta or None.""" + if isinstance(self, _NoTime): + return timedelta(0) + if isinstance(other, DatetimeWithSafeNone): + if isinstance(other, _NoTime): + return timedelta(0) + return self - other + if isinstance(other, (datetime, timedelta)): + return self._dt - other + msg = f"Cannot subtract {type(other)} from {type(self)}" + raise NotImplementedError(msg) + + +class _NoTime: + """A wrapper for None values that can be used in place of a datetime object.""" + + def __bool__(self) -> bool: + """Return False for _NoTime.""" + return False + + def __int__(self) -> int: + """Return 0 for _NoTime.""" + return 0 + + def __repr__(self) -> str: + """Return 'NoTime' for _NoTime.""" + return "NoTime" + + def __str__(self) -> str: + """Return 'NoTime' for _NoTime.""" + return "NoTime" + + def __sub__(self, other: "DatetimeWithSafeNone | _NoTime") -> datetime | timedelta: + """Subtract between None and a datetime or timedelta or None.""" + return _safe_none_diff(self, other) + + def isoformat(self) -> str: + """Return an ISO 8601-like string of 0s for display.""" + return "0000-00-00" + + +NoTime = _NoTime() +"""A singleton None that can be used in place of a datetime object.""" + + +class DatetimeWithSafeNone(datetime, _NoTime): + """Time class that can be None or a time value. + + Examples + -------- + >>> from datetime import datetime + >>> DatetimeWithSafeNone(datetime(2025, 6, 18, 21, 6, 43, 730004)).isoformat() + '2025-06-18T21:06:43.730004' + >>> DatetimeWithSafeNone("2025-06-18T21:06:43.730004").isoformat() + '2025-06-18T21:06:43.730004' + >>> DatetimeWithSafeNone(b"\\x07\\xe9\\x06\\x12\\x10\\x18\\x1c\\x88\\x6d\\x01").isoformat() + '2025-06-18T16:24:28.028040+00:00' + >>> DatetimeWithSafeNone(b'\\x07\\xe9\\x06\\x12\\x10\\x18\\x1c\\x88m\\x00').isoformat() + '2025-06-18T16:24:28.028040' + >>> DatetimeWithSafeNone(DatetimeWithSafeNone("2025-06-18")).isoformat() + '2025-06-18T00:00:00' + >>> DatetimeWithSafeNone(None) + NoTime + >>> DatetimeWithSafeNone(None).isoformat() + '0000-00-00' + """ + + @overload + def __new__( + cls, + year: "OptionalDatetime", + month: None = None, + day: None = None, + hour: None = None, + minute: None = None, + second: None = None, + microsecond: None = None, + tzinfo: None = None, + *, + fold: None = None, + ) -> "DatetimeWithSafeNone | _NoTime": ... + @overload + def __new__( + cls, + year: int, + month: Optional[int] = None, + day: Optional[int] = None, + hour: int = 0, + minute: int = 0, + second: int = 0, + microsecond: int = 0, + tzinfo: Optional[timezone | ZoneInfo] = None, + *, + fold: int = 0, + ) -> "DatetimeWithSafeNone": ... + + def __new__( + cls, + year: "int | OptionalDatetime", + month: Optional[int] = None, + day: Optional[int] = None, + hour: Optional[int] = 0, + minute: Optional[int] = 0, + second: Optional[int] = 0, + microsecond: Optional[int] = 0, + tzinfo: Optional[timezone | ZoneInfo] = None, + *, + fold: Optional[int] = 0, + ) -> "DatetimeWithSafeNone | _NoTime": + """Create a new instance of the class.""" + if ( + isinstance(year, int) + and isinstance(month, int) + and isinstance(day, int) + and isinstance(hour, int) + and isinstance(minute, int) + and isinstance(second, int) + and isinstance(microsecond, int) + and isinstance(fold, int) + ): + return datetime.__new__( + cls, + year, + month, + day, + hour, + minute, + second, + microsecond, + tzinfo, + fold=fold, + ) + else: + dt = year + if dt is None: + return NoTime + if isinstance(dt, datetime): + return datetime.__new__( + cls, + dt.year, + dt.month, + dt.day, + dt.hour, + dt.minute, + dt.second, + dt.microsecond, + dt.tzinfo, + ) + if isinstance(dt, bytes): + try: + tzflag: Optional[int] + year, month, day, hour, minute, second = struct.unpack(">H5B", dt[:7]) + microsecond, tzflag = struct.unpack(" bool: + """Return True if not NoTime.""" + return self is not NoTime + + def __sub__(self, other: "DatetimeWithSafeNone | _NoTime") -> datetime | timedelta: # type: ignore[reportIncompatibleMethodOverride] + """Subtract between a datetime or timedelta or None.""" + return _safe_none_diff(self, other) + + def __repr__(self) -> str: + """Return the string representation of the datetime or NoTime.""" + if self: + return datetime.__repr__(self) + return "NoTime" + + def __str__(self) -> str: + """Return the string representation of the datetime or NoTime.""" + return super().__str__() + + @staticmethod + def sync_tz( + one: "DatetimeWithSafeNone", two: "DatetimeWithSafeNone" + ) -> tuple[datetime, datetime]: + """Add timezone to other if one datetime is aware and other isn't .""" + if one.tzinfo is None and two.tzinfo is not None: + return one.replace(tzinfo=two.tzinfo), two + if one.tzinfo is not None and two.tzinfo is None: + return one, two.replace(tzinfo=one.tzinfo) + return one, two + + +class DatetimeJSONEncoder(json.JSONEncoder): + """JSON encoder that handles DatetimeWithSafeNone instances.""" + + def default(self, o: Any) -> str: + """Convert datetime objects to ISO format.""" + if isinstance(o, datetime): + return o.isoformat() + if o is None or o is NoTime: + return "" + return super().default(o) + + +def json_dumps(obj: Any, **kwargs) -> str: + """Convert an object to a JSON string.""" + return json.dumps(obj, cls=DatetimeJSONEncoder, **kwargs) + + +OptionalDatetime: TypeAlias = Optional[ + datetime | str | bytes | DatetimeWithSafeNone | _NoTime +] +"""Type alias for a datetime, ISO-format string or None.""" + + def recurse_nodes(workflow, prefix=""): + """Log initial information from all the nodes.""" for node in nx.topological_sort(workflow._graph): if isinstance(node, pe.Workflow): for subnode in recurse_nodes(node, prefix + workflow.name + "."): @@ -29,7 +303,7 @@ def recurse_nodes(workflow, prefix=""): def log_nodes_initial(workflow): logger = getLogger("callback") for node in recurse_nodes(workflow): - logger.debug(json.dumps(node)) + logger.debug(json_dumps(node)) def log_nodes_cb(node, status): @@ -111,8 +385,8 @@ def log_nodes_cb(node, status): status_dict = { "id": str(node), "hash": node.inputs.get_hashval()[1], - "start": getattr(runtime, "startTime", None), - "finish": getattr(runtime, "endTime", None), + "start": DatetimeWithSafeNone(getattr(runtime, "startTime", None)), + "finish": DatetimeWithSafeNone(getattr(runtime, "endTime", None)), "runtime_threads": runtime_threads, "runtime_memory_gb": getattr(runtime, "mem_peak_gb", "N/A"), "estimated_memory_gb": node.mem_gb, @@ -122,10 +396,12 @@ def log_nodes_cb(node, status): if hasattr(node, "input_data_shape") and node.input_data_shape is not Undefined: status_dict["input_data_shape"] = node.input_data_shape - if status_dict["start"] is None or status_dict["finish"] is None: + if any( + not isinstance(status_dict[label], datetime) for label in ["start", "finish"] + ): status_dict["error"] = True - logger.debug(json.dumps(status_dict)) + logger.debug(json_dumps(status_dict)) log_nodes_cb.__doc__ = f"""{_nipype_log_nodes_cb.__doc__} @@ -155,7 +431,7 @@ def handle(self): with open(callback_file, "rb") as lf: for l in lf.readlines(): # noqa: E741 - l = l.strip() # noqa: E741 + l = l.strip() # noqa: E741,PLW2901 try: node = json.loads(l) if node["id"] not in tree[subject]: @@ -182,7 +458,7 @@ def handle(self): tree = {s: t for s, t in tree.items() if t} headers = "HTTP/1.1 200 OK\nConnection: close\n\n" - self.request.sendall(headers + json.dumps(tree) + "\n") + self.request.sendall(headers + json_dumps(tree) + "\n") class LoggingHTTPServer(socketserver.ThreadingTCPServer, object): diff --git a/CPAC/utils/ndmg_utils.py b/CPAC/utils/ndmg_utils.py index 0623118e75..1680e8edf6 100644 --- a/CPAC/utils/ndmg_utils.py +++ b/CPAC/utils/ndmg_utils.py @@ -32,7 +32,6 @@ # Modifications Copyright (C) 2022-2024 C-PAC Developers # This file is part of C-PAC. -from logging import basicConfig, INFO import os import numpy as np @@ -41,7 +40,6 @@ from CPAC.utils.monitoring.custom_logging import getLogger logger = getLogger("nuerodata.m2g.ndmg") -basicConfig(format="%(message)s", level=INFO) def ndmg_roi_timeseries(func_file, label_file): diff --git a/CPAC/utils/outputs.py b/CPAC/utils/outputs.py index 11b81eb60f..f148bba87d 100644 --- a/CPAC/utils/outputs.py +++ b/CPAC/utils/outputs.py @@ -1,13 +1,36 @@ +# Copyright (C) 2018-2025 C-PAC Developers + +# This file is part of C-PAC. + +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. + +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . +"""Specify the resources that C-PAC writes to the output direcotry.""" + +from importlib.resources import files +from typing import ClassVar + import pandas as pd -import pkg_resources as p class Outputs: - # Settle some things about the resource pool reference and the output directory - reference_csv = p.resource_filename("CPAC", "resources/cpac_outputs.tsv") + """Settle some things about the resource pool reference and the output directory.""" + + reference_csv = str(files("CPAC").joinpath("resources/cpac_outputs.tsv")) try: - reference = pd.read_csv(reference_csv, delimiter="\t", keep_default_na=False) + reference: ClassVar[pd.DataFrame] = pd.read_csv( + reference_csv, delimiter="\t", keep_default_na=False + ) except Exception as e: err = ( "\n[!] Could not access or read the cpac_outputs.tsv " @@ -27,8 +50,12 @@ class Outputs: reference[reference["4D Time Series"] == "Yes"]["Resource"] ) - anat = list(reference[reference["Sub-Directory"] == "anat"]["Resource"]) - func = list(reference[reference["Sub-Directory"] == "func"]["Resource"]) + anat: ClassVar[list[str]] = list( + reference[reference["Sub-Directory"] == "anat"]["Resource"] + ) + func: ClassVar[list[str]] = list( + reference[reference["Sub-Directory"] == "func"]["Resource"] + ) # outputs to send into smoothing, if smoothing is enabled, and # outputs to write out if the user selects to write non-smoothed outputs @@ -45,6 +72,8 @@ class Outputs: all_template_filter = _template_filter | _epitemplate_filter | _symtemplate_filter all_native_filter = _T1w_native_filter | _bold_native_filter | _long_native_filter + bold_native: ClassVar[list[str]] = list(reference[_bold_native_filter]["Resource"]) + native_nonsmooth = list( reference[all_native_filter & _nonsmoothed_filter]["Resource"] ) @@ -101,3 +130,11 @@ def _is_gifti(_file_key): for gifti in giftis.itertuples() if " " in gifti.File } + + +def group_derivatives(pull_func: bool = False) -> list[str]: + """Gather keys for anatomical and functional derivatives for group analysis.""" + derivatives: list[str] = Outputs.func + Outputs.anat + if pull_func: + derivatives = derivatives + Outputs.bold_native + return derivatives diff --git a/CPAC/utils/symlinks.py b/CPAC/utils/symlinks.py index c9283394de..3494243e4a 100644 --- a/CPAC/utils/symlinks.py +++ b/CPAC/utils/symlinks.py @@ -1,3 +1,21 @@ +# Copyright (C) 2019-2025 C-PAC Developers + +# This file is part of C-PAC. + +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. + +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . +"""Create symbolic links.""" + from collections import defaultdict import errno import os @@ -5,6 +23,7 @@ output_renamings = { "anatomical_brain": "anat", "anatomical_brain_mask": "anat", + "anatomical_reorient": "anat", "qc": "qc", "anatomical_skull_leaf": "anat", "anatomical_to_mni_linear_xfm": "anat", diff --git a/CPAC/utils/test_mocks.py b/CPAC/utils/test_mocks.py index 336488f318..85967c8aeb 100644 --- a/CPAC/utils/test_mocks.py +++ b/CPAC/utils/test_mocks.py @@ -1,4 +1,24 @@ +# Copyright (C) 2019-2025 C-PAC Developers + +# This file is part of C-PAC. + +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. + +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . +"""Mock configuration and strategy for testing purposes.""" + import os +from pathlib import Path +from typing import Literal from nipype.interfaces import utility as util @@ -9,16 +29,20 @@ from CPAC.utils.strategy import Strategy -def file_node(path, file_node_num=0): +def file_node( + path: Path | str, file_node_num: int = 0, name: str = "file_node" +) -> tuple[pe.Node, Literal["file"]]: + """Create a file node with the given path and name.""" input_node = pe.Node( util.IdentityInterface(fields=["file"]), - name=f"file_node_{file_node_num}", + name=f"{name}_{file_node_num}", ) - input_node.inputs.file = path + input_node.inputs.file = str(path) return input_node, "file" def configuration_strategy_mock(method="FSL"): + """Mock configuration and strategy for testing.""" fsldir = os.environ.get("FSLDIR") # mock the config dictionary c = Configuration( @@ -235,6 +259,7 @@ def configuration_strategy_mock(method="FSL"): resampled_template.inputs.template = template resampled_template.inputs.template_name = template_name resampled_template.inputs.tag = tag + resampled_template.inputs.orientation = "RPI" strat.update_resource_pool( {template_name: (resampled_template, "resampled_template")} diff --git a/CPAC/utils/test_resources.py b/CPAC/utils/test_resources.py index da58e4e0f9..5d447292f6 100644 --- a/CPAC/utils/test_resources.py +++ b/CPAC/utils/test_resources.py @@ -1,4 +1,4 @@ -# Copyright (C) 2019-2024 C-PAC Developers +# Copyright (C) 2019-2025 C-PAC Developers # This file is part of C-PAC. @@ -14,29 +14,32 @@ # You should have received a copy of the GNU Lesser General Public # License along with C-PAC. If not, see . -from CPAC.utils.monitoring import WFLOGGER +"""Resources for testing utilities.""" +import os +import shutil +from typing import Optional -def setup_test_wf(s3_prefix, paths_list, test_name, workdirs_to_keep=None): - """Set up a basic template Nipype workflow for testing single nodes or - small sub-workflows. - """ - import os - import shutil +from CPAC.pipeline import nipype_pipeline_engine as pe +from CPAC.utils.datasource import check_for_s3 +from CPAC.utils.interfaces.datasink import DataSink +from CPAC.utils.monitoring import WFLOGGER - from CPAC.pipeline import nipype_pipeline_engine as pe - from CPAC.utils.datasource import check_for_s3 - from CPAC.utils.interfaces.datasink import DataSink - test_dir = os.path.join(os.getcwd(), test_name) +def setup_test_wf( + s3_prefix, + paths_list, + test_name, + workdirs_to_keep=None, + test_dir: Optional[str] = None, +) -> tuple[pe.Workflow, pe.Node, dict[str, str]]: + """Set up a basic template Nipype workflow for testing small workflows.""" + test_dir = os.path.join(test_dir if test_dir else os.getcwd(), test_name) work_dir = os.path.join(test_dir, "workdir") out_dir = os.path.join(test_dir, "output") if os.path.exists(out_dir): - try: - shutil.rmtree(out_dir) - except: - pass + shutil.rmtree(out_dir, ignore_errors=True) if os.path.exists(work_dir): for dirname in os.listdir(work_dir): @@ -45,10 +48,7 @@ def setup_test_wf(s3_prefix, paths_list, test_name, workdirs_to_keep=None): WFLOGGER.info("%s --- %s\n", dirname, keepdir) if keepdir in dirname: continue - try: - shutil.rmtree(os.path.join(work_dir, dirname)) - except: - pass + shutil.rmtree(os.path.join(work_dir, dirname), ignore_errors=True) local_paths = {} for subpath in paths_list: @@ -67,4 +67,4 @@ def setup_test_wf(s3_prefix, paths_list, test_name, workdirs_to_keep=None): ds.inputs.base_directory = out_dir ds.inputs.parameterization = True - return (wf, ds, local_paths) + return wf, ds, local_paths diff --git a/CPAC/utils/tests/configs/__init__.py b/CPAC/utils/tests/configs/__init__.py index f8a23bd4e6..896c79bf69 100644 --- a/CPAC/utils/tests/configs/__init__.py +++ b/CPAC/utils/tests/configs/__init__.py @@ -1,15 +1,21 @@ """Configs for testing.""" -from pathlib import Path +from importlib import resources + +try: + from importlib.resources.abc import Traversable +except ModuleNotFoundError: # TODO: Remove this block once minimum Python version includes `importlib.resources.abc` + from importlib.abc import Traversable -from pkg_resources import resource_filename import yaml -_TEST_CONFIGS_PATH = Path(resource_filename("CPAC", "utils/tests/configs")) -with open(_TEST_CONFIGS_PATH / "neurostars_23786.yml", "r", encoding="utf-8") as _f: +_TEST_CONFIGS_PATH: Traversable = resources.files("CPAC").joinpath( + "utils/tests/configs" +) +with (_TEST_CONFIGS_PATH / "neurostars_23786.yml").open("r", encoding="utf-8") as _f: # A loaded YAML file to test https://tinyurl.com/neurostars23786 NEUROSTARS_23786 = _f.read() -with open(_TEST_CONFIGS_PATH / "neurostars_24035.yml", "r", encoding="utf-8") as _f: +with (_TEST_CONFIGS_PATH / "neurostars_24035.yml").open("r", encoding="utf-8") as _f: # A loaded YAML file to test https://tinyurl.com/neurostars24035 NEUROSTARS_24035 = _f.read() # A loaded YAML file to test https://tinyurl.com/cmicnlslack420349 diff --git a/CPAC/utils/tests/configs/github_2144.yml b/CPAC/utils/tests/configs/github_2144.yml new file mode 100644 index 0000000000..a7d405c8ea --- /dev/null +++ b/CPAC/utils/tests/configs/github_2144.yml @@ -0,0 +1,8 @@ +- site: site-1 + subject_id: 01 + unique_id: 02 + derivatives_dir: /fprep/sub-0151 +- site: site-1 + subject_id: !!str 02 + unique_id: 02 + derivatives_dir: /fprep/sub-0151 diff --git a/CPAC/utils/tests/osf.py b/CPAC/utils/tests/osf.py new file mode 100644 index 0000000000..5ddbcc0c25 --- /dev/null +++ b/CPAC/utils/tests/osf.py @@ -0,0 +1,44 @@ +# Copyright (C) 2025 C-PAC Developers + +# This file is part of C-PAC. + +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. + +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . +"""Open Science Framework testing utilities.""" + +import os +from pathlib import Path + +import requests + +FILES = {"residuals.nii.gz": "kyqad", "regressors.1D": "xzuyf"} + + +def download_file(file: str, destination: Path | str) -> Path: + """Download a file from the Open Science Framework.""" + url = f"https://osf.io/download/{FILES[file]}" + response = requests.get( + url, + headers={"Authorization": f"Bearer {os.getenv('OSF_DATA')}"}, + allow_redirects=True, + ) + if not isinstance(destination, Path): + destination = Path(destination) + destination = destination / file if destination.is_dir() else destination + if destination.exists(): + msg = f"File {destination} already exists. Please remove it before downloading." + raise FileExistsError(msg) + response.raise_for_status() + with open(destination, "wb") as f: + f.write(response.content) + return destination diff --git a/CPAC/utils/tests/test_bids_utils.py b/CPAC/utils/tests/test_bids_utils.py index 57c0abef56..2b7267af94 100644 --- a/CPAC/utils/tests/test_bids_utils.py +++ b/CPAC/utils/tests/test_bids_utils.py @@ -16,7 +16,7 @@ # License along with C-PAC. If not, see . """Tests for bids_utils.""" -from logging import basicConfig, INFO +from importlib import resources import os from subprocess import run @@ -24,17 +24,18 @@ import yaml from CPAC.utils.bids_utils import ( + _check_value_type, bids_gen_cpac_sublist, cl_strip_brackets, collect_bids_files_configs, create_cpac_data_config, load_cpac_data_config, + load_yaml_config, sub_list_filter_by_labels, ) from CPAC.utils.monitoring.custom_logging import getLogger logger = getLogger("CPAC.utils.tests") -basicConfig(format="%(message)s", level=INFO) def create_sample_bids_structure(root_dir): @@ -109,6 +110,19 @@ def test_gen_bids_sublist(bids_dir, test_yml, creds_path, dbg=False): assert sublist +def test_load_data_config_with_ints() -> None: + """Check that C-PAC coerces sub- and ses- ints to strings.""" + data_config_file = resources.files("CPAC").joinpath( + "utils/tests/configs/github_2144.yml" + ) + # make sure there are ints in the test data + assert _check_value_type(load_yaml_config(str(data_config_file), None)) + # make sure there aren't ints when it's loaded through the loader + assert not _check_value_type( + load_cpac_data_config(str(data_config_file), None, None) + ) + + @pytest.mark.parametrize("t1w_label", ["acq-HCP", "acq-VNavNorm", "T1w", None]) @pytest.mark.parametrize( "bold_label", ["task-peer_run-1", "[task-peer_run-1 task-peer_run-2]", "bold", None] diff --git a/CPAC/utils/tests/test_datasource.py b/CPAC/utils/tests/test_datasource.py index be7c2255c2..61ec5b655d 100644 --- a/CPAC/utils/tests/test_datasource.py +++ b/CPAC/utils/tests/test_datasource.py @@ -1,4 +1,4 @@ -# Copyright (C) 2019-2024 C-PAC Developers +# Copyright (C) 2019-2025 C-PAC Developers # This file is part of C-PAC. @@ -14,72 +14,689 @@ # You should have received a copy of the GNU Lesser General Public # License along with C-PAC. If not, see . +"""Test datasource utilities.""" + +from dataclasses import dataclass import json +from pathlib import Path +from typing import Any, Literal, TypeAlias +from unittest.mock import mock_open, patch +from networkx.classes.digraph import DiGraph import pytest from CPAC.pipeline import nipype_pipeline_engine as pe -from CPAC.utils.datasource import match_epi_fmaps -from CPAC.utils.interfaces import Function +from CPAC.utils.datasource import ( + match_epi_fmaps, + match_epi_fmaps_function_node, +) from CPAC.utils.test_resources import setup_test_wf +from CPAC.utils.utils import ( + get_fmap_build_info, + get_fmap_metadata_at_build_time, + get_fmap_type, + PE_DIRECTION, +) + + +@dataclass +class MatchEpiFmapsInputs: + """Store test data for `match_epi_fmaps`.""" + + bold_pedir: PE_DIRECTION + epi_fmaps: list[tuple[str, dict[str, Any]]] + + +def match_epi_fmaps_inputs( + generate: bool, path: Path +) -> tuple[pe.Workflow, MatchEpiFmapsInputs]: + """Return inputs for `~CPAC.utils.datasource.match_epi_fmaps`.""" + if generate: + # good data to use + s3_prefix = "s3://fcp-indi/data/Projects/HBN/MRI/Site-CBIC/sub-NDARAB708LM5" + s3_paths = [ + "func/sub-NDARAB708LM5_task-rest_run-1_bold.json", + "fmap/sub-NDARAB708LM5_dir-PA_acq-fMRI_epi.nii.gz", + "fmap/sub-NDARAB708LM5_dir-PA_acq-fMRI_epi.json", + "fmap/sub-NDARAB708LM5_dir-AP_acq-fMRI_epi.nii.gz", + "fmap/sub-NDARAB708LM5_dir-AP_acq-fMRI_epi.json", + ] + wf, ds, local_paths = setup_test_wf( + s3_prefix, s3_paths, "test_match_epi_fmaps", test_dir=str(path) + ) -@pytest.mark.skip(reason="needs refactoring") -def test_match_epi_fmaps(): - # good data to use - s3_prefix = "s3://fcp-indi/data/Projects/HBN/MRI/Site-CBIC/sub-NDARAB708LM5" - s3_paths = [ - "func/sub-NDARAB708LM5_task-rest_run-1_bold.json", - "fmap/sub-NDARAB708LM5_dir-PA_acq-fMRI_epi.nii.gz", - "fmap/sub-NDARAB708LM5_dir-PA_acq-fMRI_epi.json", - "fmap/sub-NDARAB708LM5_dir-AP_acq-fMRI_epi.nii.gz", - "fmap/sub-NDARAB708LM5_dir-AP_acq-fMRI_epi.json", + opposite_pe_json = local_paths["fmap/sub-NDARAB708LM5_dir-PA_acq-fMRI_epi.json"] + same_pe_json = local_paths["fmap/sub-NDARAB708LM5_dir-AP_acq-fMRI_epi.json"] + func_json = local_paths["func/sub-NDARAB708LM5_task-rest_run-1_bold.json"] + + with open(opposite_pe_json, "r") as f: + opposite_pe_params = json.load(f) + + with open(same_pe_json, "r") as f: + same_pe_params = json.load(f) + + with open(func_json, "r") as f: + func_params = json.load(f) + bold_pedir = func_params["PhaseEncodingDirection"] + + fmap_paths_dct = { + "epi_PA": { + "scan": local_paths["fmap/sub-NDARAB708LM5_dir-PA_acq-fMRI_epi.nii.gz"], + "scan_parameters": opposite_pe_params, + }, + "epi_AP": { + "scan": local_paths["fmap/sub-NDARAB708LM5_dir-AP_acq-fMRI_epi.nii.gz"], + "scan_parameters": same_pe_params, + }, + } + ds.inputs.func_json = func_json + ds.inputs.opposite_pe_json = opposite_pe_json + ds.inputs.same_pe_json = same_pe_json + return wf, MatchEpiFmapsInputs( + bold_pedir, + [ + (scan["scan"], scan["scan_parameters"]) + for scan in fmap_paths_dct.values() + ], + ) + _paths = [ + f"{path}/sub-NDARAB514MAJ_dir-AP_acq-fMRI_epi.nii.gz", + f"{path}/sub-NDARAB514MAJ_dir-PA_acq-fMRI_epi.nii.gz", ] + for _ in _paths: + Path(_).touch(exist_ok=True) + return pe.Workflow("test_match_epi_fmaps", path), MatchEpiFmapsInputs( + "j-", + [ + ( + _paths[0], + { + "AcquisitionMatrixPE": 84, + "BandwidthPerPixelPhaseEncode": 23.81, + "BaseResolution": 84, + "BodyPartExamined": b"BRAIN", + "ConsistencyInfo": b"N4_VE11B_LATEST_20150530", + "ConversionSoftware": b"dcm2niix", + "ConversionSoftwareVersion": b"v1.0.20171215 GCC4.8.4", + "DerivedVendorReportedEchoSpacing": 0.00049999, + "DeviceSerialNumber": b"67080", + "DwellTime": 2.6e-06, + "EchoTime": 0.0512, + "EchoTrainLength": 84, + "EffectiveEchoSpacing": 0.00049999, + "FlipAngle": 90, + "ImageOrientationPatientDICOM": [1, 0, 0, 0, 1, 0], + "ImageType": ["ORIGINAL", "PRIMARY", "M", "ND", "MOSAIC"], + "InPlanePhaseEncodingDirectionDICOM": b"COL", + "MRAcquisitionType": b"2D", + "MagneticFieldStrength": 3, + "Manufacturer": b"Siemens", + "ManufacturersModelName": b"Prisma_fit", + "Modality": b"MR", + "PartialFourier": 1, + "PatientPosition": b"HFS", + "PercentPhaseFOV": 100, + "PhaseEncodingDirection": b"j-", + "PhaseEncodingSteps": 84, + "PhaseResolution": 1, + "PixelBandwidth": 2290, + "ProcedureStepDescription": b"CMI_HBN-CBIC", + "ProtocolName": b"cmrr_fMRI_DistortionMap_AP", + "PulseSequenceDetails": b"%CustomerSeq%_cmrr_mbep2d_se", + "ReceiveCoilActiveElements": b"HEA;HEP", + "ReceiveCoilName": b"Head_32", + "ReconMatrixPE": 84, + "RepetitionTime": 5.301, + "SAR": 0.364379, + "ScanOptions": b"FS", + "ScanningSequence": b"EP", + "SequenceName": b"epse2d1_84", + "SequenceVariant": b"SK", + "SeriesDescription": b"cmrr_fMRI_DistortionMap_AP", + "ShimSetting": [208, -10464, -5533, 615, -83, -88, 55, 30], + "SliceThickness": 2.4, + "SliceTiming": [ + 2.64, + 0, + 2.7275, + 0.0875, + 2.815, + 0.175, + 2.9025, + 0.2625, + 2.9925, + 0.3525, + 3.08, + 0.44, + 3.1675, + 0.5275, + 3.255, + 0.615, + 3.3425, + 0.7025, + 3.4325, + 0.7925, + 3.52, + 0.88, + 3.6075, + 0.9675, + 3.695, + 1.055, + 3.785, + 1.1425, + 3.8725, + 1.2325, + 3.96, + 1.32, + 4.0475, + 1.4075, + 4.135, + 1.495, + 4.225, + 1.5825, + 4.3125, + 1.6725, + 4.4, + 1.76, + 4.4875, + 1.8475, + 4.575, + 1.935, + 4.665, + 2.0225, + 4.7525, + 2.1125, + 4.84, + 2.2, + 4.9275, + 2.2875, + 5.015, + 2.375, + 5.105, + 2.4625, + 5.1925, + 2.5525, + ], + "SoftwareVersions": b"syngo_MR_E11", + "SpacingBetweenSlices": 2.4, + "StationName": b"MRTRIO3TX72", + "TotalReadoutTime": 0.0414992, + "TxRefAmp": 209.923, + }, + ), + ( + _paths[1], + { + "AcquisitionMatrixPE": 84, + "BandwidthPerPixelPhaseEncode": 23.81, + "BaseResolution": 84, + "BodyPartExamined": b"BRAIN", + "ConsistencyInfo": b"N4_VE11B_LATEST_20150530", + "ConversionSoftware": b"dcm2niix", + "ConversionSoftwareVersion": b"v1.0.20171215 GCC4.8.4", + "DerivedVendorReportedEchoSpacing": 0.00049999, + "DeviceSerialNumber": b"67080", + "DwellTime": 2.6e-06, + "EchoTime": 0.0512, + "EchoTrainLength": 84, + "EffectiveEchoSpacing": 0.00049999, + "FlipAngle": 90, + "ImageOrientationPatientDICOM": [1, 0, 0, 0, 1, 0], + "ImageType": ["ORIGINAL", "PRIMARY", "M", "ND", "MOSAIC"], + "InPlanePhaseEncodingDirectionDICOM": b"COL", + "MRAcquisitionType": b"2D", + "MagneticFieldStrength": 3, + "Manufacturer": b"Siemens", + "ManufacturersModelName": b"Prisma_fit", + "Modality": b"MR", + "PartialFourier": 1, + "PatientPosition": b"HFS", + "PercentPhaseFOV": 100, + "PhaseEncodingDirection": b"j", + "PhaseEncodingSteps": 84, + "PhaseResolution": 1, + "PixelBandwidth": 2290, + "ProcedureStepDescription": b"CMI_HBN-CBIC", + "ProtocolName": b"cmrr_fMRI_DistortionMap_PA", + "PulseSequenceDetails": b"%CustomerSeq%_cmrr_mbep2d_se", + "ReceiveCoilActiveElements": b"HEA;HEP", + "ReceiveCoilName": b"Head_32", + "ReconMatrixPE": 84, + "RepetitionTime": 5.301, + "SAR": 0.364379, + "ScanOptions": b"FS", + "ScanningSequence": b"EP", + "SequenceName": b"epse2d1_84", + "SequenceVariant": b"SK", + "SeriesDescription": b"cmrr_fMRI_DistortionMap_PA", + "ShimSetting": [208, -10464, -5533, 615, -83, -88, 55, 30], + "SliceThickness": 2.4, + "SliceTiming": [ + 2.64, + 0, + 2.73, + 0.09, + 2.8175, + 0.1775, + 2.905, + 0.265, + 2.9925, + 0.3525, + 3.08, + 0.44, + 3.17, + 0.53, + 3.2575, + 0.6175, + 3.345, + 0.705, + 3.4325, + 0.7925, + 3.52, + 0.88, + 3.61, + 0.97, + 3.6975, + 1.0575, + 3.785, + 1.145, + 3.8725, + 1.2325, + 3.9625, + 1.32, + 4.05, + 1.41, + 4.1375, + 1.4975, + 4.225, + 1.585, + 4.3125, + 1.6725, + 4.4025, + 1.76, + 4.49, + 1.85, + 4.5775, + 1.9375, + 4.665, + 2.025, + 4.7525, + 2.1125, + 4.8425, + 2.2, + 4.93, + 2.29, + 5.0175, + 2.3775, + 5.105, + 2.465, + 5.1925, + 2.5525, + ], + "SoftwareVersions": b"syngo_MR_E11", + "SpacingBetweenSlices": 2.4, + "StationName": b"MRTRIO3TX72", + "TotalReadoutTime": 0.0414992, + "TxRefAmp": 209.923, + }, + ), + ], + ) - wf, ds, local_paths = setup_test_wf(s3_prefix, s3_paths, "test_match_epi_fmaps") - opposite_pe_json = local_paths["fmap/sub-NDARAB708LM5_dir-PA_acq-fMRI_epi.json"] - same_pe_json = local_paths["fmap/sub-NDARAB708LM5_dir-AP_acq-fMRI_epi.json"] - func_json = local_paths["func/sub-NDARAB708LM5_task-rest_run-1_bold.json"] +RunType: TypeAlias = Literal["nipype"] | Literal["direct"] +Direction: TypeAlias = Literal["opposite"] | Literal["same"] - with open(opposite_pe_json, "r") as f: - opposite_pe_params = json.load(f) - with open(same_pe_json, "r") as f: - same_pe_params = json.load(f) +@pytest.mark.parametrize("generate", [True, False]) +def test_match_epi_fmaps(generate: bool, tmp_path: Path) -> None: + """Test `~CPAC.utils.datasource.match_epi_fmaps`.""" + wf, data = match_epi_fmaps_inputs(generate, tmp_path) - with open(func_json, "r") as f: - func_params = json.load(f) - bold_pedir = func_params["PhaseEncodingDirection"] + match_fmaps = match_epi_fmaps_function_node() + match_fmaps.inputs.bold_pedir = data.bold_pedir + match_fmaps.inputs.epi_fmap_one = data.epi_fmaps[0][0] + match_fmaps.inputs.epi_fmap_params_one = data.epi_fmaps[0][1] + match_fmaps.inputs.epi_fmap_two = data.epi_fmaps[1][0] + match_fmaps.inputs.epi_fmap_params_two = data.epi_fmaps[1][1] - fmap_paths_dct = { - "epi_PA": { - "scan": local_paths["fmap/sub-NDARAB708LM5_dir-PA_acq-fMRI_epi.nii.gz"], - "scan_parameters": opposite_pe_params, - }, - "epi_AP": { - "scan": local_paths["fmap/sub-NDARAB708LM5_dir-AP_acq-fMRI_epi.nii.gz"], - "scan_parameters": same_pe_params, + wf.add_nodes([match_fmaps]) + + graph: DiGraph = wf.run() + result = list(graph.nodes)[-1].run() + str_outputs: dict[RunType, dict[Direction, str]] = { + "nipype": { + "opposite": result.outputs.opposite_pe_epi, + "same": result.outputs.same_pe_epi, }, + "direct": {}, } + path_outputs: dict[RunType, dict[Direction, Path]] = {"nipype": {}, "direct": {}} + str_outputs["direct"]["opposite"], str_outputs["direct"]["same"] = match_epi_fmaps( + data.bold_pedir, + data.epi_fmaps[0][0], + data.epi_fmaps[0][1], + data.epi_fmaps[1][0], + data.epi_fmaps[1][1], + ) + directions: list[Direction] = ["opposite", "same"] + runtypes: list[RunType] = ["nipype", "direct"] + for direction in directions: + for runtype in runtypes: + path_outputs[runtype][direction] = Path(str_outputs[runtype][direction]) + assert path_outputs[runtype][direction].exists() + assert ( + path_outputs["nipype"][direction].name + == path_outputs["direct"][direction].name + ) + - match_fmaps = pe.Node( - Function( - input_names=["fmap_dct", "bold_pedir"], - output_names=["opposite_pe_epi", "same_pe_epi"], - function=match_epi_fmaps, - as_module=True, +@pytest.mark.parametrize( + "metadata, expected_type", + [ + # Case 1: Phase-difference map (phasediff) - REQUIRED: EchoTime1 and EchoTime2 + ({"EchoTime1": 0.00600, "EchoTime2": 0.00746}, "phasediff"), + ({"EchoTime1": 0.004, "EchoTime2": 0.006}, "phasediff"), + # Case 2: Single phase map (phase) - REQUIRED: EchoTime, but NOT PhaseEncodingDirection + ({"EchoTime": 0.00746}, "phase"), + ({"EchoTime": 0.004}, "phase"), + # Case 3: EPI field maps (epi) - REQUIRED: PhaseEncodingDirection + ({"PhaseEncodingDirection": "j-"}, "epi"), + ({"PhaseEncodingDirection": "j"}, "epi"), + ({"PhaseEncodingDirection": "i"}, "epi"), + ({"PhaseEncodingDirection": "i-"}, "epi"), + ({"PhaseEncodingDirection": "k"}, "epi"), + ({"PhaseEncodingDirection": "k-"}, "epi"), + # Edge cases and invalid inputs + ({}, None), # Empty metadata + # Priority testing - phasediff should take precedence over everything + ({"EchoTime1": 0.006, "EchoTime2": 0.007, "EchoTime": 0.006}, "phasediff"), + ( + {"EchoTime1": 0.006, "EchoTime2": 0.007, "PhaseEncodingDirection": "j-"}, + "phasediff", ), - name="match_epi_fmaps", + # EPI should take precedence when PhaseEncodingDirection is present (even with EchoTime) + ({"EchoTime": 0.006, "PhaseEncodingDirection": "j-"}, "epi"), + # Test with optional fields that might be present (but shouldn't affect detection) + ( + { + "EchoTime1": 0.006, + "EchoTime2": 0.007, + "IntendedFor": "bids::sub-01/func/sub-01_task-motor_bold.nii.gz", + }, + "phasediff", + ), + ({"PhaseEncodingDirection": "j-", "TotalReadoutTime": 0.095}, "epi"), + ({"EchoTime": 0.006, "TotalReadoutTime": 0.095}, "phase"), + # Test invalid PhaseEncodingDirection values (should return epi for valid values) + ( + {"PhaseEncodingDirection": "invalid"}, + "epi", + ), # Current implementation returns epi for any PE direction + ( + {"PhaseEncodingDirection": "AP"}, + "epi", + ), # Current implementation returns epi for any PE direction + ( + {"PhaseEncodingDirection": "PA"}, + "epi", + ), # Current implementation returns epi for any PE direction + ( + {"PhaseEncodingDirection": ""}, + "epi", + ), # Current implementation returns epi for any PE direction + # Test fieldmap type (currently implemented and working) + ({"Units": "rad/s"}, "fieldmap"), + ({"Units": "Hz"}, "fieldmap"), + ({"Units": "hz"}, "fieldmap"), + ({"Units": "T"}, "fieldmap"), + ({"Units": "Tesla"}, "fieldmap"), + ({"Units": "hertz"}, "fieldmap"), + # Mixed cases with Units - fieldmap takes precedence in current implementation + ( + {"Units": "Hz", "PhaseEncodingDirection": "j-"}, + "fieldmap", + ), # fieldmap takes precedence + ( + {"EchoTime": 0.006, "Units": "Hz"}, + "phase", + ), # Phase takes precedence over fieldmap + # Test with bytes values (common in real data) - current implementation handles these + ( + {"PhaseEncodingDirection": b"j-"}, + "epi", + ), # Current implementation returns epi for bytes + # Test case sensitivity - current implementation handles these + ( + {"PhaseEncodingDirection": "J-"}, + "epi", + ), # Current implementation returns epi regardless of case + ], +) +def test_get_fmap_type_dict_input(metadata: dict, expected_type: str | None) -> None: + """Test `get_fmap_type` with dictionary input using only required BIDS fields.""" + result = get_fmap_type(metadata) + assert result == expected_type + + +def test_get_fmap_type_real_world_examples() -> None: + """Test `get_fmap_type` with realistic BIDS metadata examples (required fields only).""" + # Real-world phasediff example (only required fields) + phasediff_metadata = { + "EchoTime1": 0.00600, + "EchoTime2": 0.00746, + # Optional fields that might be present: + "IntendedFor": ["bids::sub-01/func/sub-01_task-motor_bold.nii.gz"], + } + assert get_fmap_type(phasediff_metadata) == "phasediff" + + # Real-world fieldmap example (only required fields) + fieldmap_metadata = { + "Units": "rad/s", + # Optional fields that might be present: + "IntendedFor": "bids::sub-01/func/sub-01_task-motor_bold.nii.gz", + } + assert get_fmap_type(fieldmap_metadata) == "fieldmap" + + # Real-world EPI example (only required fields) + epi_metadata = { + "PhaseEncodingDirection": "j-", + # Optional fields that might be present: + "TotalReadoutTime": 0.095, + "IntendedFor": "bids::sub-01/func/sub-01_task-motor_bold.nii.gz", + } + assert get_fmap_type(epi_metadata) == "epi" + + # Real-world phase example (only required fields) + phase_metadata = {"EchoTime": 0.00746} + assert get_fmap_type(phase_metadata) == "phase" + + +class TestGetFmapMetadataAtBuildTime: + """Test get_fmap_metadata_at_build_time function.""" + + def test_missing_fmap_key(self): + """Test when fieldmap key doesn't exist in sub_dict.""" + sub_dict = {"fmap": {"other_key": {}}} + result = get_fmap_metadata_at_build_time(sub_dict, "missing_key", "", "") + assert result is None + + def test_missing_scan_parameters(self): + """Test when scan_parameters field is missing.""" + sub_dict = {"fmap": {"test_key": {"scan": "path/to/scan.nii.gz"}}} + result = get_fmap_metadata_at_build_time(sub_dict, "test_key", "", "") + assert result is None + + def test_direct_dict_metadata(self): + """Test when metadata is provided as a direct dictionary.""" + metadata = {"EchoTime1": 0.006, "EchoTime2": 0.007} + sub_dict = {"fmap": {"test_key": {"scan_parameters": metadata}}} + result = get_fmap_metadata_at_build_time(sub_dict, "test_key", "", "") + assert result == metadata + + @patch("builtins.open", new_callable=mock_open, read_data='{"EchoTime": 0.006}') + @patch("os.path.exists", return_value=True) + def test_json_file_metadata(self, mock_exists, mock_file): + """Test loading metadata from JSON file.""" + sub_dict = {"fmap": {"test_key": {"scan_parameters": "/path/to/metadata.json"}}} + result = get_fmap_metadata_at_build_time(sub_dict, "test_key", "", "") + assert result == {"EchoTime": 0.006} + mock_file.assert_called_once_with( + "/path/to/metadata.json", "r", encoding="utf-8" + ) + + @patch("os.path.exists", return_value=False) + def test_nonexistent_file(self, mock_exists): + """Test when JSON file doesn't exist.""" + sub_dict = {"fmap": {"test_key": {"scan_parameters": "/nonexistent/file.json"}}} + result = get_fmap_metadata_at_build_time(sub_dict, "test_key", "", "") + assert result is None + + @patch("builtins.open", side_effect=json.JSONDecodeError("Invalid JSON", "", 0)) + @patch("os.path.exists", return_value=True) + def test_invalid_json(self, mock_exists, mock_file): + """Test when JSON file contains invalid JSON.""" + sub_dict = {"fmap": {"test_key": {"scan_parameters": "/path/to/invalid.json"}}} + result = get_fmap_metadata_at_build_time(sub_dict, "test_key", "", "") + assert result is None + + def test_non_json_file(self): + """Test when file path doesn't end with .json.""" + sub_dict = {"fmap": {"test_key": {"scan_parameters": "/path/to/file.txt"}}} + result = get_fmap_metadata_at_build_time(sub_dict, "test_key", "", "") + assert result is None + + def test_exception_handling(self): + """Test general exception handling.""" + sub_dict = {"fmap": {"test_key": {"scan_parameters": 123}}} # Invalid type + result = get_fmap_metadata_at_build_time(sub_dict, "test_key", "", "") + assert result is None + + +class TestGetFmapBuildInfo: + """Test get_fmap_build_info function.""" + + def test_none_metadata_raises_error(self): + """Test that None metadata raises ValueError.""" + with pytest.raises( + ValueError, match="Fieldmap metadata dictionary is required" + ): + get_fmap_build_info(None) + + def test_empty_metadata_raises_error(self): + """Test that empty metadata raises ValueError.""" + with pytest.raises( + ValueError, match="Fieldmap metadata dictionary is required" + ): + get_fmap_build_info({}) + + def test_unknown_fmap_type_raises_error(self): + """Test that unknown fieldmap type raises ValueError.""" + metadata = {"SomeUnknownField": "value"} + with pytest.raises(ValueError, match="Could not determine fieldmap type"): + get_fmap_build_info(metadata) + + def test_phase_fieldmap_info(self): + """Test phase fieldmap build info.""" + metadata = {"EchoTime": 0.006} + result = get_fmap_build_info(metadata) + expected = { + "fmap_type": "phase", + "needs_echo_times": True, + "needs_phasediff_processing": True, + "is_epi": False, + } + assert result == expected + + def test_phasediff_fieldmap_info(self): + """Test phasediff fieldmap build info.""" + metadata = {"EchoTime1": 0.006, "EchoTime2": 0.007} + result = get_fmap_build_info(metadata) + expected = { + "fmap_type": "phasediff", + "needs_echo_times": True, + "needs_phasediff_processing": True, + "is_epi": False, + } + assert result == expected + + def test_epi_fieldmap_info(self): + """Test EPI fieldmap build info.""" + metadata = {"PhaseEncodingDirection": "j-"} + result = get_fmap_build_info(metadata) + expected = { + "fmap_type": "epi", + "needs_echo_times": True, + "needs_phasediff_processing": False, + "is_epi": True, + } + assert result == expected + + @pytest.mark.parametrize( + "metadata,expected_fmap_type", + [ + ({"EchoTime": 0.006}, "phase"), + ({"EchoTime1": 0.006, "EchoTime2": 0.007}, "phasediff"), + ({"PhaseEncodingDirection": "j-"}, "epi"), + ], ) - match_fmaps.inputs.fmap_dct = fmap_paths_dct - match_fmaps.inputs.bold_pedir = bold_pedir + def test_various_fieldmap_types(self, metadata, expected_fmap_type): + """Test that various fieldmap types are correctly identified.""" + result = get_fmap_build_info(metadata) + assert result["fmap_type"] == expected_fmap_type + + def test_real_world_metadata_examples(self): + """Test with realistic metadata examples from the existing tests.""" + # Use some of the test data from the existing test_get_fmap_type tests + + # Phasediff example + phasediff_metadata = { + "EchoTime1": 0.00600, + "EchoTime2": 0.00746, + "IntendedFor": ["bids::sub-01/func/sub-01_task-motor_bold.nii.gz"], + } + result = get_fmap_build_info(phasediff_metadata) + assert result["fmap_type"] == "phasediff" + assert result["needs_echo_times"] is True + assert result["needs_phasediff_processing"] is True + assert result["is_epi"] is False - ds.inputs.func_json = func_json - ds.inputs.opposite_pe_json = opposite_pe_json - ds.inputs.same_pe_json = same_pe_json + # EPI example + epi_metadata = { + "PhaseEncodingDirection": "j-", + "TotalReadoutTime": 0.095, + "IntendedFor": "bids::sub-01/func/sub-01_task-motor_bold.nii.gz", + } + result = get_fmap_build_info(epi_metadata) + assert result["fmap_type"] == "epi" + assert result["needs_echo_times"] is True + assert result["needs_phasediff_processing"] is False + assert result["is_epi"] is True - wf.connect(match_fmaps, "opposite_pe_epi", ds, "should_be_dir-PA") - wf.connect(match_fmaps, "same_pe_epi", ds, "should_be_dir-AP") + def test_phase_fieldmap_with_extra_fields(self): + """Test phase fieldmap with additional optional fields.""" + metadata = { + "EchoTime": 0.006, + "IntendedFor": "bids::sub-01/func/sub-01_task-motor_bold.nii.gz", + "B0FieldIdentifier": "my_fieldmap", + } + result = get_fmap_build_info(metadata) + assert result["fmap_type"] == "phase" + assert result["needs_echo_times"] is True + assert result["needs_phasediff_processing"] is True + assert result["is_epi"] is False - wf.run() + def test_phasediff_fieldmap_with_extra_fields(self): + """Test phasediff fieldmap with additional optional fields.""" + metadata = { + "EchoTime1": 0.006, + "EchoTime2": 0.007, + "IntendedFor": ["bids::sub-01/func/sub-01_task-motor_bold.nii.gz"], + "B0FieldIdentifier": "my_phasediff", + } + result = get_fmap_build_info(metadata) + assert result["fmap_type"] == "phasediff" + assert result["needs_echo_times"] is True + assert result["needs_phasediff_processing"] is True + assert result["is_epi"] is False diff --git a/CPAC/utils/tests/test_symlinks.py b/CPAC/utils/tests/test_symlinks.py index 570d2e9b74..d271ea752d 100644 --- a/CPAC/utils/tests/test_symlinks.py +++ b/CPAC/utils/tests/test_symlinks.py @@ -1,4 +1,4 @@ -# Copyright (C) 2019-2024 C-PAC Developers +# Copyright (C) 2019-2025 C-PAC Developers # This file is part of C-PAC. @@ -14,37 +14,30 @@ # You should have received a copy of the GNU Lesser General Public # License along with C-PAC. If not, see . -from logging import basicConfig, INFO -import os -import tempfile +"""Test symbolic links.""" -import pkg_resources as p +from importlib.resources import as_file, files +import tempfile from CPAC.utils.monitoring.custom_logging import getLogger from CPAC.utils.symlinks import create_symlinks logger = getLogger("CPAC.utils.tests") -basicConfig(format="%(message)s", level=INFO) - -mocked_outputs = p.resource_filename( - "CPAC", os.path.join("utils", "tests", "test_symlinks-outputs.txt") -) def test_symlinks(): temp_dir = tempfile.mkdtemp(suffix="test_symlinks") - paths = [] - with open(mocked_outputs, "r") as f: - for _path in f.readlines(): - path = _path - path = path.strip() - if path: - paths += [path] - - create_symlinks( - temp_dir, "sym_links", "pipeline_benchmark-FNIRT", "1019436_1", paths - ) + paths: list[str] = [] + with as_file(files("CPAC").joinpath("utils/tests/test_symlinks-outputs.txt")) as _f: + with _f.open("r") as f: + for _path in f.readlines(): + path = _path + path = path.strip() + if path: + paths += [path] + + create_symlinks(temp_dir, "pipeline_benchmark-FNIRT", "1019436_1", paths) logger.info("Links created at %s", temp_dir) diff --git a/CPAC/utils/tests/test_trimmer.py b/CPAC/utils/tests/test_trimmer.py index 1d1f7361f7..60e2ceff2f 100644 --- a/CPAC/utils/tests/test_trimmer.py +++ b/CPAC/utils/tests/test_trimmer.py @@ -1,3 +1,21 @@ +# Copyright (C) 2020-2025 C-PAC Developers + +# This file is part of C-PAC. + +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. + +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . +"""Test The Trimmer.""" + from copy import copy import tempfile @@ -11,11 +29,11 @@ def accept_all(object, name, value): @pytest.mark.skip(reason="needs refactored") def test_trimmer(): + """Test The Trimmer.""" import os - import pkg_resources as p - from CPAC.pipeline.cpac_pipeline import build_workflow + from CPAC.resources.configs import CONFIGS_PATH from CPAC.utils.configuration import Configuration from CPAC.utils.trimmer import ( compute_datasink_dirs, @@ -24,18 +42,13 @@ def test_trimmer(): the_trimmer, ) - pipe_config = p.resource_filename( - "CPAC", os.path.join("resources", "configs", "pipeline_config_template.yml") - ) - - data_config = p.resource_filename( - "CPAC", os.path.join("resources", "configs", "data_config_S3-BIDS-ABIDE.yml") - ) + pipe_config = CONFIGS_PATH / "pipeline_config_template.yml" + data_config = CONFIGS_PATH / "data_config_S3-BIDS-ABIDE.yml" - data_config = yaml.safe_load(open(data_config, "r")) + data_config = yaml.safe_load(data_config.open("r")) sub_dict = data_config[0] - c = Configuration(yaml.safe_load(open(pipe_config, "r"))) + c = Configuration(yaml.safe_load(pipe_config.open("r"))) temp_dir = tempfile.mkdtemp() c.logDirectory = temp_dir c.workingDirectory = temp_dir diff --git a/CPAC/utils/tests/test_utils.py b/CPAC/utils/tests/test_utils.py index ab896c6029..6c9d111048 100644 --- a/CPAC/utils/tests/test_utils.py +++ b/CPAC/utils/tests/test_utils.py @@ -1,15 +1,33 @@ +# Copyright (C) 2018-2025 C-PAC Developers + +# This file is part of C-PAC. + +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. + +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . """Tests of CPAC utility functions.""" +from datetime import datetime, timedelta import multiprocessing from unittest import mock from _pytest.logging import LogCaptureFixture import pytest -from CPAC.func_preproc import get_motion_ref -from CPAC.pipeline.nodeblock import NodeBlockFunction +from CPAC.func_preproc.func_motion import get_motion_ref +from CPAC.pipeline.nodeblock import NodeBlockFunction, POOL_RESOURCE_MAPPING from CPAC.utils.configuration import Configuration from CPAC.utils.monitoring.custom_logging import log_subprocess +from CPAC.utils.monitoring.monitoring import DatetimeWithSafeNone, OptionalDatetime from CPAC.utils.tests import old_functions from CPAC.utils.utils import ( check_config_resources, @@ -157,6 +175,7 @@ def test_NodeBlock_option_SSOT(): # pylint: disable=invalid-name with pytest.raises(ValueError) as value_error: get_motion_ref(None, None, None, None, opt="chaos") error_message = str(value_error.value).rstrip() + assert get_motion_ref.option_val for opt in get_motion_ref.option_val: assert f"'{opt}'" in error_message assert error_message.endswith("Tool input: 'chaos'") @@ -168,3 +187,56 @@ def test_system_deps(): Raises an exception if dependencies are not met. """ check_system_deps(*([True] * 4)) + + +def check_expected_keys( + sink_native_transforms: bool, outputs: POOL_RESOURCE_MAPPING, expected_keys: set +) -> None: + """Check if expected keys are present in outputs based on sink_native_transforms.""" + if sink_native_transforms: + assert expected_keys.issubset( + outputs.keys() + ), f"Expected outputs {expected_keys} not found in {outputs.keys()}" + else: + assert not expected_keys.intersection( + outputs.keys() + ), f"Outputs {expected_keys} should not be present when sink_native_transforms is Off" + + +@pytest.mark.parametrize( + "t1", + [ + datetime.now(), + datetime.now().astimezone(), + datetime.isoformat(datetime.now()), + None, + ], +) +@pytest.mark.parametrize( + "t2", + [ + datetime.now(), + datetime.now().astimezone(), + datetime.isoformat(datetime.now()), + None, + ], +) +def test_datetime_with_safe_none(t1: OptionalDatetime, t2: OptionalDatetime): + """Test DatetimeWithSafeNone class works with datetime and None.""" + originals = t1, t2 + t1 = DatetimeWithSafeNone(t1) + t2 = DatetimeWithSafeNone(t2) + if t1 and t2: + _tzinfos = [getattr(_, "tzinfo", None) for _ in originals] + if ( + all(isinstance(_, datetime) for _ in originals) + and any(_tzinfos) + and not all(_tzinfos) + ): + with pytest.raises(TypeError): + originals[1] - originals[0] # type: ignore[reportOperatorIssue] + _t1, _t2 = DatetimeWithSafeNone.sync_tz(*originals) # type: ignore[reportArgumentType] + assert isinstance(_t2 - _t1, timedelta) + assert isinstance(t2 - t1, timedelta) + else: + assert t2 - t1 == timedelta(0) diff --git a/CPAC/utils/utils.py b/CPAC/utils/utils.py index b459262993..a2b60f8390 100644 --- a/CPAC/utils/utils.py +++ b/CPAC/utils/utils.py @@ -1,4 +1,4 @@ -# Copyright (C) 2012-2024 C-PAC Developers +# Copyright (C) 2012-2025 C-PAC Developers # This file is part of C-PAC. @@ -20,32 +20,27 @@ from copy import deepcopy import fnmatch import gzip -from itertools import repeat +from importlib.resources import files import json import numbers import os import pickle -from typing import Any, Literal, Optional, overload +from typing import Any, Literal, Optional, overload, TypedDict import numpy as np from voluptuous.error import Invalid import yaml +from CPAC.resources import configs from CPAC.utils.configuration import Configuration from CPAC.utils.docs import deprecated from CPAC.utils.interfaces.function import Function from CPAC.utils.monitoring import FMLOGGER, WFLOGGER -CONFIGS_DIR = os.path.abspath( - os.path.join(__file__, *repeat(os.path.pardir, 2), "resources/configs/") -) -with open( - os.path.join(CONFIGS_DIR, "1.7-1.8-nesting-mappings.yml"), "r", encoding="utf-8" -) as _f: +CONFIGS_DIR = files(configs) +with (CONFIGS_DIR / "1.7-1.8-nesting-mappings.yml").open("r", encoding="utf-8") as _f: NESTED_CONFIG_MAPPING = yaml.safe_load(_f) -with open( - os.path.join(CONFIGS_DIR, "1.7-1.8-deprecations.yml"), "r", encoding="utf-8" -) as _f: +with (CONFIGS_DIR / "1.7-1.8-deprecations.yml").open("r", encoding="utf-8") as _f: NESTED_CONFIG_DEPRECATIONS = yaml.safe_load(_f) PE_DIRECTION = Literal["i", "i-", "j", "j-", "k", "k-", ""] VALID_PATTERNS = [ @@ -73,7 +68,7 @@ def get_last_prov_entry(prov): return prov[-1] -def check_prov_for_regtool(prov): +def check_prov_for_regtool(prov) -> Optional[Literal["ants", "fsl"]]: """Check provenance for registration tool.""" last_entry = get_last_prov_entry(prov) last_node = last_entry.split(":")[1] @@ -101,22 +96,6 @@ def check_prov_for_regtool(prov): return None -def check_prov_for_motion_tool(prov): - """Check provenance for motion correction tool.""" - last_entry = get_last_prov_entry(prov) - last_node = last_entry.split(":")[1] - if "3dvolreg" in last_node.lower(): - return "3dvolreg" - if "mcflirt" in last_node.lower(): - return "mcflirt" - # check entire prov - if "3dvolreg" in str(prov): - return "3dvolreg" - if "mcflirt" in str(prov): - return "mcflirt" - return None - - def _get_flag(in_flag): return in_flag @@ -525,6 +504,9 @@ def check(self, val_to_check: str, throw_exception: bool): msg = f"Missing value for {val_to_check} for participant {self.subject}." raise ValueError(msg) + if isinstance(ret_val, bytes): + ret_val = ret_val.decode("utf-8") + return ret_val @overload @@ -631,6 +613,8 @@ def fetch_and_convert( f" ≅ '{matched_keys[1]}'." ) if convert_to: + if isinstance(raw_value, bytes): + raw_value = raw_value.decode("utf-8") try: value = convert_to(raw_value) except (TypeError, ValueError): @@ -957,6 +941,47 @@ def add_afni_prefix(tpattern): return tpattern +def afni_3dwarp(in_file, out_file=None, deoblique=False): + """ + Run AFNI's 3dWarp command with optional deobliquing. + + Parameters + ---------- + in_file : str + Path to the input NIfTI file. + out_file : str or None + Path for the output file. If None, a name will be generated in the current directory. + deoblique : bool + If True, adds the '-deoblique' flag to the 3dWarp command. + + Returns + ------- + out_file : str + Path to the output file. + """ + import os + import subprocess + + if not out_file: + base = os.path.basename(in_file) + base = base.replace(".nii.gz", "").replace(".nii", "") + suffix = "_deoblique" if deoblique else "_warped" + out_file = os.path.abspath(f"{base}{suffix}.nii.gz") + + cmd = ["3dWarp"] + if deoblique: + cmd.append("-deoblique") + cmd += ["-prefix", out_file, in_file] + + try: + subprocess.check_output(cmd, stderr=subprocess.STDOUT) + except subprocess.CalledProcessError as e: + msg = f"3dWarp failed with error:\n{e.output.decode()}" + raise RuntimeError(msg) + + return out_file + + def write_to_log(workflow, log_dir, index, inputs, scan_id): """Write into log file the status of the workflow run.""" import datetime @@ -1117,10 +1142,10 @@ def create_log(wf_name="log", scan_id=None): def find_files(directory, pattern): """Find files in directory.""" - for root, dirs, files in os.walk(directory): - for basename in files: + for _root, _dirs, _files in os.walk(directory): + for basename in _files: if fnmatch.fnmatch(basename, pattern): - filename = os.path.join(root, basename) + filename = os.path.join(_root, basename) yield filename @@ -1414,8 +1439,8 @@ def repickle(directory): # noqa: T20 ------- None """ - for root, _, files in os.walk(directory, followlinks=True): - for fn in files: + for root, _, _files in os.walk(directory, followlinks=True): + for fn in _files: p = os.path.join(root, fn) if fn.endswith(".pkl"): if _pickle2(p): @@ -1605,16 +1630,6 @@ def _changes_1_8_0_to_1_8_1(config_dict: dict) -> dict: del config_dict["functional_preproc"]["motion_estimates_and_correction"][ "calculate_motion_first" ] - config_dict = set_nested_value( - config_dict, - [ - "functional_preproc", - "motion_estimates_and_correction", - "motion_estimates", - "calculate_motion_first", - ], - calculate_motion_first, - ) return config_dict @@ -2631,3 +2646,203 @@ def _replace_in_value_list(current_value, replacement_tuple): for v in current_value if bool(v) and v not in {"None", "Off", ""} ] + + +def flip_orientation_code(code): + """Reverts an orientation code by flipping R↔L, A↔P, and I↔S.""" + flip_dict = {"R": "L", "L": "R", "A": "P", "P": "A", "I": "S", "S": "I"} + return "".join(flip_dict[c] for c in code) + + +def get_fmap_type(metadata): + """Determine the type of field map from metadata. + + reference: https://bids-specification.readthedocs.io/en/v1.10.0/modality-specific-files/magnetic-resonance-imaging-data.html#case-1-phase-difference-map-and-at-least-one-magnitude-image + + Parameters + ---------- + metadata : dict or str + Metadata dictionary or path to a JSON file containing metadata. + + Returns + ------- + str or None + Returns the type of field map as a string: + - "phasediff" for phase difference maps with two echo times + - "phase" for single echo phase maps + - "fieldmap" for field maps with units like Hz, rad/s, T, or Tesla + - "epi" for EPI field maps with phase encoding direction + """ + if not isinstance(metadata, dict): + if isinstance(metadata, str) and ".json" in metadata: + import json + + try: + with open(metadata, "r", encoding="utf-8") as f: + metadata = json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return None + else: + return None + + # Check for required BIDS fields only + match ( + "EchoTime1" in metadata, + "EchoTime2" in metadata, + "EchoTime" in metadata, + "Units" in metadata, + "PhaseEncodingDirection" in metadata, + ): + case (True, True, _, _, _): + # Case 1: Phase-difference map (REQUIRED: EchoTime1 AND EchoTime2) + return "phasediff" + case (False, False, True, _, False): + # Case 2: Single phase map (REQUIRED: EchoTime, but NOT EchoTime1/2) + return "phase" + case (_, _, _, True, _): + # Case 3: Direct field mapping (REQUIRED: Units) + units = metadata["Units"].lower() + if units in ["hz", "rad/s", "t", "tesla", "hertz"]: + return "fieldmap" + return None + case (_, _, _, _, True): + # Case 4: EPI field maps (REQUIRED: PhaseEncodingDirection) + return "epi" + case _: + return None + + return None + + +def get_fmap_metadata_at_build_time(sub_dict, orig_key, input_creds_path, dl_dir): + """Extract fieldmap metadata during workflow build time. + + Parameters + ---------- + sub_dict : dict + Subject dictionary containing fieldmap information + orig_key : str + Original fieldmap key name + input_creds_path : str + Path to AWS credentials + dl_dir : str + Download directory path + + Returns + ------- + dict + Dictionary containing fieldmap metadata, or None if unavailable + """ + import json + import os + + try: + # Check if scan_parameters exists for this fieldmap + if orig_key not in sub_dict["fmap"]: + return None + + if "scan_parameters" not in sub_dict["fmap"][orig_key]: + return None + + scan_params_path = sub_dict["fmap"][orig_key]["scan_parameters"] + + # Handle dictionary metadata (direct dict) + if isinstance(scan_params_path, dict): + return scan_params_path + + # Handle file path metadata + if isinstance(scan_params_path, str): + local_path = scan_params_path + + # Handle S3 paths + if scan_params_path.startswith("s3://"): + try: + local_path = check_for_s3( + scan_params_path, input_creds_path, dl_dir + ) + except Exception: + return None + + # Load JSON file + if local_path.endswith(".json") and os.path.exists(local_path): + with open(local_path, "r", encoding="utf-8") as f: + return json.load(f) + + except (FileNotFoundError, json.JSONDecodeError, KeyError, Exception): + pass + + return None + + +class FmapBuildInfo(TypedDict): + """Fieldmap metadata.""" + + fmap_type: Optional[str] + needs_echo_times: bool + needs_phasediff_processing: bool + is_epi: bool + + +@Function.sig_imports( + ["from typing import Optional", "from CPAC.utils.utils import FmapBuildInfo"] +) +def get_fmap_build_info(metadata_dict: Optional[dict]) -> FmapBuildInfo: + """Determine fieldmap processing requirements at build time. + + Parameters + ---------- + metadata_dict + Fieldmap metadata dictionary + + Raises + ------ + ValueError + If metadata_dict is None or if fieldmap type cannot be determined + """ + from CPAC.utils.utils import get_fmap_type + + if not metadata_dict: + raise ValueError( + "Fieldmap metadata dictionary is required but was None. " + "Cannot determine fieldmap processing requirements without metadata." + ) + + fmap_type = get_fmap_type(metadata_dict) + + if fmap_type is None: + msg = ( + f"Could not determine fieldmap type from metadata: {metadata_dict}. " + "Metadata must contain required BIDS fields for fieldmap type detection." + ) + raise ValueError(msg) + + build_info = { + "fmap_type": fmap_type, + "needs_echo_times": False, + "needs_phasediff_processing": False, + "is_epi": False, + } + + match fmap_type: + case "phase": + build_info["needs_echo_times"] = True + build_info["needs_phasediff_processing"] = True + + case "phasediff": + build_info["needs_echo_times"] = True + build_info["needs_phasediff_processing"] = True + + case "epi": + build_info["needs_echo_times"] = True + build_info["is_epi"] = True + + case "fieldmap": + build_info["needs_phasediff_processing"] = True + + case _: + raise ValueError( + f"Unsupported fieldmap type '{fmap_type}'. " + "Supported types are: 'phase', 'phasediff', 'epi', 'fieldmap'." + ) + + return build_info diff --git a/CPAC/vmhc/tests/test_vmhc.py b/CPAC/vmhc/tests/test_vmhc.py index 2471a9b02c..e66d3cd782 100644 --- a/CPAC/vmhc/tests/test_vmhc.py +++ b/CPAC/vmhc/tests/test_vmhc.py @@ -14,7 +14,6 @@ # You should have received a copy of the GNU Lesser General Public # License along with C-PAC. If not, see . -from logging import basicConfig, INFO import os import pytest @@ -25,7 +24,6 @@ from CPAC.vmhc.vmhc import vmhc as create_vmhc logger = getLogger("CPAC.utils.tests") -basicConfig(format="%(message)s", level=INFO) @pytest.mark.skip(reason="test needs refactoring") diff --git a/CPAC/vmhc/vmhc.py b/CPAC/vmhc/vmhc.py index 3c547a8e2f..ddb2f57c60 100644 --- a/CPAC/vmhc/vmhc.py +++ b/CPAC/vmhc/vmhc.py @@ -1,3 +1,21 @@ +# Copyright (C) 2012-2025 C-PAC Developers + +# This file is part of C-PAC. + +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. + +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . +"""Voxel-Mirrored Homotopic Connectivity.""" + from nipype.interfaces import fsl from nipype.interfaces.afni import preprocess @@ -5,7 +23,6 @@ from CPAC.pipeline import nipype_pipeline_engine as pe from CPAC.pipeline.nodeblock import nodeblock from CPAC.registration.registration import apply_transform -from CPAC.utils.utils import check_prov_for_regtool from CPAC.vmhc import * from .utils import * @@ -60,8 +77,7 @@ def smooth_func_vmhc(wf, cfg, strat_pool, pipe_num, opt=None): outputs=["space-symtemplate_desc-sm_bold"], ) def warp_timeseries_to_sym_template(wf, cfg, strat_pool, pipe_num, opt=None): - xfm_prov = strat_pool.get_cpac_provenance("from-bold_to-symtemplate_mode-image_xfm") - reg_tool = check_prov_for_regtool(xfm_prov) + reg_tool = strat_pool.reg_tool("from-bold_to-symtemplate_mode-image_xfm") num_cpus = cfg.pipeline_setup["system_config"]["max_cores_per_participant"] diff --git a/Dockerfile b/Dockerfile index 838d8dcc4b..e41bd6fc73 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,14 +15,14 @@ # You should have received a copy of the GNU Lesser General Public # License along with C-PAC. If not, see . FROM ghcr.io/fcp-indi/c-pac/stage-base:standard-v1.8.8.dev1 -LABEL org.opencontainers.image.description "Full C-PAC image" -LABEL org.opencontainers.image.source https://github.com/FCP-INDI/C-PAC +LABEL org.opencontainers.image.description="Full C-PAC image" +LABEL org.opencontainers.image.source=https://github.com/FCP-INDI/C-PAC USER root # install C-PAC COPY dev/circleci_data/pipe-test_ci.yml /cpac_resources/pipe-test_ci.yml COPY . /code -RUN pip cache purge && pip install -e "/code[graphviz]" +RUN pip cache purge && pip install backports.tarfile && pip install -e "/code[graphviz]" # set up runscript COPY dev/docker_data /code/docker_data RUN rm -Rf /code/docker_data/checksum && \ @@ -45,7 +45,8 @@ RUN rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /root/.cache/* \ && chmod 777 $(ls / | grep -v sys | grep -v proc) ENV PYTHONUSERBASE=/home/c-pac_user/.local ENV PATH=$PATH:/home/c-pac_user/.local/bin \ - PYTHONPATH=$PYTHONPATH:$PYTHONUSERBASE/lib/python3.10/site-packages + PYTHONPATH=$PYTHONPATH:$PYTHONUSERBASE/lib/python3.10/site-packages \ + _SHELL=/bin/bash # set user WORKDIR /home/c-pac_user diff --git a/README.md b/README.md index 137bc57972..6bc400be3e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ C-PAC is distributed in the hope that it will be useful, but WITHOUT ANY WARRANT You should have received a copy of the GNU Lesser General Public License along with C-PAC. If not, see . --> C-PAC: Configurable Pipeline for the Analysis of Connectomes ============================================================ -[![DOI for "Moving Beyond Processing and Analysis-Related Variation in Neuroscience"](https://zenodo.org/badge/DOI/10.1101/2021.12.01.470790.svg)](https://doi.org/10.1101/2021.12.01.470790) [![DOI for "FCP-INDI/C-PAC: CPAC Version 1.0.0 Beta"](https://zenodo.org/badge/DOI/10.5281/zenodo.164638.svg)](https://doi.org/10.5281/zenodo.164638) + +[![DOI for "Moving Beyond Processing and Analysis-Related Variation in Neuroscience"](https://zenodo.org/badge/DOI/10.1101/2021.12.01.470790.svg)](https://doi.org/10.1101/2021.12.01.470790) [![DOI for "FCP-INDI/C-PAC: CPAC Version 1.0.0 Beta"](https://zenodo.org/badge/DOI/10.5281/zenodo.164638.svg)](https://doi.org/10.5281/zenodo.164638) [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/FCP-INDI/C-PAC/main.svg)](https://results.pre-commit.ci/latest/github/FCP-INDI/C-PAC/main) [![codecov](https://codecov.io/github/FCP-INDI/C-PAC/graph/badge.svg?token=sWxXoDRf1M)](https://codecov.io/github/FCP-INDI/C-PAC) [![LGPL](https://www.gnu.org/graphics/lgplv3-88x31.png)](./COPYING.LESSER) @@ -17,6 +18,9 @@ A configurable, open-source, Nipype-based, automated processing pipeline for res Designed for use by both novice users and experts, C-PAC brings the power, flexibility and elegance of Nipype to users in a plug-and-play fashion; no programming required. +> [!WARNING] +> C-PAC entered maintenance mode in version 1.8.8. See [SUPPORT.md](./SUPPORT.md). + Website ------- diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000000..16eb8b642f --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,13 @@ +Support Policy +============== + +As of v1.8.8, C-PAC is in maintenance mode. With the 2.0.0 release, we will begin strict adherence to Semantic Versioning. + +While in maintenance mode, we will continue to publish new releases but FCP-INDI will no longer be developing new features. +Community contributions will be reviewed and released when passing review. Responsibility for these reviews is defined in [.github/CODEOWNERS](./.github/CODEOWNERS). + +User support will continue at [Neurostars](https://neurostars.org/tag/cpac), though expect a slower response time. + +Major bug fixes will continue to be addressed by [**@FCP-INDI/maintenance**](https://github.com/orgs/FCP-INDI/teams/maintenance). Minor bugs will be documented and left to the community to contribute fixes and workarounds. + +Security releases will continue to be published by [**@FCP-INDI/DevOps**](https://github.com/orgs/FCP-INDI/teams/DevOps). diff --git a/dev/circleci_data/conftest.py b/dev/circleci_data/conftest.py new file mode 100644 index 0000000000..4966b986c5 --- /dev/null +++ b/dev/circleci_data/conftest.py @@ -0,0 +1,19 @@ +# Copyright (C) 2025 C-PAC Developers + +# This file is part of C-PAC. + +# C-PAC is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. + +# C-PAC is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +# License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with C-PAC. If not, see . +"""Global fixtures for C-PAC tests.""" + +from CPAC._global_fixtures import * # noqa: F403 diff --git a/dev/circleci_data/data_settings_bids_examples_ds051_default_BIDS.yml b/dev/circleci_data/data_settings_bids_examples_ds051_default_BIDS.yml index 5449692350..c196250ac8 100644 --- a/dev/circleci_data/data_settings_bids_examples_ds051_default_BIDS.yml +++ b/dev/circleci_data/data_settings_bids_examples_ds051_default_BIDS.yml @@ -15,7 +15,7 @@ dataFormat: BIDS # BIDS Data Format only. # # This should be the path to the overarching directory containing the entire dataset. -bidsBaseDir: ./bids-examples/ds051 +bidsBaseDir: ./ds051 # File Path Template for Anatomical Files @@ -49,7 +49,7 @@ awsCredentialsFile: None # Directory where CPAC should place data configuration files. -outputSubjectListLocation: ./dev/circleci_data +outputSubjectListLocation: /code/dev/circleci_data # A label to be appended to the generated participant list files. diff --git a/dev/circleci_data/requirements.txt b/dev/circleci_data/requirements.txt index b59c3413be..fdd988a669 100644 --- a/dev/circleci_data/requirements.txt +++ b/dev/circleci_data/requirements.txt @@ -1,4 +1,4 @@ -coverage +coverage >= 7.10.1 GitPython pytest pytest_bdd diff --git a/dev/circleci_data/test_external_utils.py b/dev/circleci_data/test_external_utils.py index f516b0c903..c55e264c8b 100644 --- a/dev/circleci_data/test_external_utils.py +++ b/dev/circleci_data/test_external_utils.py @@ -31,8 +31,6 @@ from CPAC.__main__ import utils as CPAC_main_utils # noqa: E402 -# pylint: disable=wrong-import-position - def _click_backport(command, key): """Switch back to underscores for older versions of click.""" @@ -93,18 +91,11 @@ def test_build_data_config(caplog, cli_runner, multiword_connector): _delete_test_yaml(test_yaml) -def test_new_settings_template(caplog, cli_runner): +def test_new_settings_template(bids_examples: Path, caplog, cli_runner): """Test CLI ``utils new-settings-template``.""" caplog.set_level(INFO) - os.chdir(CPAC_DIR) - - example_dir = os.path.join(CPAC_DIR, "bids-examples") - if not os.path.exists(example_dir): - from git import Repo - - Repo.clone_from( - "https://github.com/bids-standard/bids-examples.git", example_dir - ) + assert bids_examples.exists() + os.chdir(bids_examples) result = cli_runner.invoke( CPAC_main_utils.commands[ diff --git a/dev/circleci_data/test_in_image.sh b/dev/circleci_data/test_in_image.sh index b62de84994..d03b6e8015 100755 --- a/dev/circleci_data/test_in_image.sh +++ b/dev/circleci_data/test_in_image.sh @@ -1,5 +1,8 @@ export PATH=$PATH:/home/$(whoami)/.local/bin +# don't force SSH for git clones in testing image +git config --global --unset url.ssh://git@github.com.insteadof + # install testing requirements pip install -r /code/dev/circleci_data/requirements.txt diff --git a/dev/docker_data/required_afni_pkgs.txt b/dev/docker_data/required_afni_pkgs.txt index 4aa745c906..acd32981e9 100644 --- a/dev/docker_data/required_afni_pkgs.txt +++ b/dev/docker_data/required_afni_pkgs.txt @@ -30,6 +30,7 @@ linux_openmp_64/3dTproject linux_openmp_64/3dTshift linux_openmp_64/3dTstat linux_openmp_64/3dvolreg +linux_openmp_64/3dWarp linux_openmp_64/afni linux_openmp_64/libcoxplot.a linux_openmp_64/libcoxplot.so diff --git a/dev/docker_data/unpinned_requirements.txt b/dev/docker_data/unpinned_requirements.txt index 186fee9168..d4db5ce5bb 100644 --- a/dev/docker_data/unpinned_requirements.txt +++ b/dev/docker_data/unpinned_requirements.txt @@ -1,3 +1,8 @@ +# Copyright (C) 2023-2025 C-PAC Developers +# This file is part of C-PAC. +# C-PAC is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. +# C-PAC is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. +# You should have received a copy of the GNU Lesser General Public License along with C-PAC. If not, see . boto3 ciftify click @@ -23,7 +28,6 @@ pybids PyPEER @ https://github.com/shnizzedy/PyPEER/archive/6965d2b2bea0fef824e885fec33a8e0e6bd50a97.zip python-dateutil PyYAML -requests scikit-learn scipy sdcflows diff --git a/pyproject.toml b/pyproject.toml index 13181c224b..84ffa2ad8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,5 +16,24 @@ # License along with C-PAC. If not, see . [build-system] -requires = ["nipype==1.8.6", "numpy==1.25.1", "pyyaml==6.0", "setuptools<60.0", "voluptuous==0.13.1"] +requires = ["nipype==1.8.6", "numpy==1.25.1", "pyyaml==6.0", "setuptools<60.0", "voluptuous==0.15.2"] build-backend = "setuptools.build_meta" + +[tool.coverage.paths] +source = [ + "/code", + "/home/circleci/project" +] + +[tool.coverage.report] +ignore_errors = true +include_namespace_packages = true +skip_empty = true + +[tool.coverage.run] +branch = true +relative_files = true +source = [ + "CPAC", + "dev/circleci_data" +] diff --git a/requirements.txt b/requirements.txt index 58afacfa6d..94f124b98b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -# Copyright (C) 2018-2023 C-PAC Developers +# Copyright (C) 2018-2025 C-PAC Developers # This file is part of C-PAC. # C-PAC is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. # C-PAC is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. @@ -30,22 +30,22 @@ pybids==0.15.6 PyPEER @ git+https://git@github.com/ChildMindInstitute/PyPEER.git@6965d2b2bea0fef824e885fec33a8e0e6bd50a97 python-dateutil==2.8.2 PyYAML==6.0 -requests==2.32.0 +requests==2.32.3 scikit-learn==1.5.0 scipy==1.11.1 sdcflows==2.4.0 semver==3.0.1 traits==6.3.2 -voluptuous==0.13.1 +voluptuous==0.15.2 # the below are pinned specifically to match what the FSL installer installs botocore==1.31.4 charset-normalizer==3.1.0 -cryptography==42.0.3 +cryptography==44.0.1 h5py==3.8.0 importlib-metadata==6.8.0 lxml==4.9.2 pip==23.3 -setuptools==70.0.0 -urllib3==1.26.18 +setuptools==78.1.1 +urllib3==1.26.19 wheel==0.40.0 zipp==3.19.1 diff --git a/setup.py b/setup.py index 17919395d2..f22a744e2d 100755 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ -# Copyright (C) 2022-2024 C-PAC Developers +# Copyright (C) 2022-2025 C-PAC Developers # This file is part of C-PAC. @@ -84,7 +84,12 @@ def main(**extra_args): extras_require={"graphviz": ["pygraphviz"]}, configuration=configuration, scripts=glob("scripts/*"), - entry_points={"console_scripts": ["cpac = CPAC.__main__:main"]}, + entry_points={ + "console_scripts": [ + "cpac = CPAC.__main__:main", + "resource_inventory = CPAC.pipeline.resource_inventory:main", + ] + }, package_data={ "CPAC": [ "test_data/*", diff --git a/variant-lite.Dockerfile b/variant-lite.Dockerfile index b58801b519..6f350c4f18 100644 --- a/variant-lite.Dockerfile +++ b/variant-lite.Dockerfile @@ -15,15 +15,15 @@ # You should have received a copy of the GNU Lesser General Public # License along with C-PAC. If not, see . FROM ghcr.io/fcp-indi/c-pac/stage-base:lite-v1.8.8.dev1 -LABEL org.opencontainers.image.description "Full C-PAC image without FreeSurfer" -LABEL org.opencontainers.image.source https://github.com/FCP-INDI/C-PAC +LABEL org.opencontainers.image.description="Full C-PAC image without FreeSurfer" +LABEL org.opencontainers.image.source=https://github.com/FCP-INDI/C-PAC USER root # install C-PAC COPY dev/circleci_data/pipe-test_ci.yml /cpac_resources/pipe-test_ci.yml COPY . /code COPY --from=ghcr.io/fcp-indi/c-pac_templates:latest /cpac_templates /cpac_templates -RUN pip cache purge && pip install -e "/code[graphviz]" +RUN pip cache purge && pip install backports.tarfile && pip install -e "/code[graphviz]" # set up runscript COPY dev/docker_data /code/docker_data RUN rm -Rf /code/docker_data/checksum && \ @@ -46,7 +46,8 @@ RUN rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /root/.cache/* \ && chmod 777 $(ls / | grep -v sys | grep -v proc) ENV PYTHONUSERBASE=/home/c-pac_user/.local ENV PATH=$PATH:/home/c-pac_user/.local/bin \ - PYTHONPATH=$PYTHONPATH:$PYTHONUSERBASE/lib/python3.10/site-packages + PYTHONPATH=$PYTHONPATH:$PYTHONUSERBASE/lib/python3.10/site-packages \ + _SHELL=/bin/bash # set user WORKDIR /home/c-pac_user