diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bfe09d9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.cache +.mypy_cache +__pycache__ +build +localizer/src/build +localizer/src/install +localizer/cpp +.VSCodeCounter +.vscode +*archive* diff --git a/.github/workflows/cross-compile.yml b/.github/workflows/cross-compile.yml index 09f1dc7..ef3c12f 100644 --- a/.github/workflows/cross-compile.yml +++ b/.github/workflows/cross-compile.yml @@ -7,11 +7,6 @@ on: push: tags: - 'v*' - branches: - - master - - main - paths-ignore: - - '**/*.md' pull_request: workflow_dispatch: @@ -22,14 +17,14 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest, macos-14] + os: ${{ fromJSON(startsWith(github.ref, 'refs/tags/') && '["ubuntu-latest","windows-latest","macos-latest","macos-14"]' || '["ubuntu-latest","windows-latest"]') }} steps: - name: Checkout (with Git LFS) uses: actions/checkout@v4 with: fetch-depth: 0 - lfs: true + lfs: ${{ startsWith(github.ref, 'refs/tags/') }} - name: Setup Python uses: actions/setup-python@v5 @@ -41,7 +36,8 @@ jobs: python -m pip install --upgrade pip pip install conan - - name: Cache Conan packages + - name: Cache Conan packages (Windows/macOS) + if: runner.os != 'Linux' uses: actions/cache@v4 with: path: ~/.conan2 @@ -49,6 +45,18 @@ jobs: restore-keys: | ${{ runner.os }}-conan- + # Keep the legacy cache separate from the normal Linux cache: Conan may cache + # build tools such as Boost's b2, and tools built on ubuntu-latest can require + # newer glibc symbols than the manylinux2014/CentOS 7 container provides. + - name: Cache legacy Linux Conan packages + if: runner.os == 'Linux' + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/dccc-conan2-legacy + key: ${{ runner.os }}-legacy-glibc217-conan-${{ hashFiles('localizer/src/conanfile.py', 'docker/Dockerfile.core.legacy', 'scripts/docker-build-core.sh', '.github/workflows/cross-compile.yml') }} + restore-keys: | + ${{ runner.os }}-legacy-glibc217-conan- + - name: Setup Ninja uses: seanmiddleditch/gha-setup-ninja@v4 @@ -60,8 +68,8 @@ jobs: echo "CC=gcc-12" >> $GITHUB_ENV echo "CXX=g++-12" >> $GITHUB_ENV - - name: Conan profile detect (Linux/macOS) - if: runner.os != 'Windows' + - name: Conan profile detect (macOS) + if: runner.os == 'macOS' working-directory: localizer/src shell: bash run: | @@ -74,8 +82,8 @@ jobs: run: | conan profile detect --force - - name: Conan install (Linux/macOS) - if: runner.os != 'Windows' + - name: Conan install (macOS) + if: runner.os == 'macOS' working-directory: localizer/src shell: bash run: | @@ -86,8 +94,8 @@ jobs: -o onetbb/*:tbbmalloc=False \ -o onetbb/*:tbbproxy=False - - name: Configure with CMake (Linux/macOS) - if: runner.os != 'Windows' + - name: Configure with CMake (macOS) + if: runner.os == 'macOS' working-directory: localizer/src shell: bash run: | @@ -98,20 +106,72 @@ jobs: -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_INSTALL_PREFIX="./install" - - name: Build with CMake (Linux/macOS) - if: runner.os != 'Windows' + - name: Build with CMake (macOS) + if: runner.os == 'macOS' working-directory: localizer/src shell: bash run: cmake --build build --config Release - - name: Install with CMake (Linux/macOS) - if: runner.os != 'Windows' + - name: Install with CMake (macOS) + if: runner.os == 'macOS' working-directory: localizer/src shell: bash run: cmake --install build --config Release + - name: Build legacy Linux Docker image + if: runner.os == 'Linux' + shell: bash + run: | + docker build \ + --build-arg USER_UID="$(id -u)" \ + --build-arg USER_GID="$(id -g)" \ + -f docker/Dockerfile.core.legacy \ + -t dccc-core-dev:legacy-glibc217 . + + - name: Build and install with legacy Linux container + if: runner.os == 'Linux' + shell: bash + run: | + legacy_conan_home="$RUNNER_TEMP/dccc-conan2-legacy" + mkdir -p "$legacy_conan_home" + docker run --rm \ + -v "$GITHUB_WORKSPACE:/workspace" \ + -v "$legacy_conan_home:/home/dev/.conan2" \ + -e CONAN_CPPSTD=gnu17 \ + -e CONAN_BUILD_ARGS='--build=missing --build=b2/* --build=m4/* --build=autoconf/* --build=automake/* --build=libtool/* --build=pkgconf/*' \ + -e BUILD_TYPE=Release \ + -e INSTALL_PREFIX=./install \ + dccc-core-dev:legacy-glibc217 \ + /workspace/scripts/docker-build-core.sh + + - name: Verify Linux glibc compatibility floor + if: runner.os == 'Linux' + working-directory: localizer/src + shell: bash + run: | + set -euo pipefail + mapfile -t elf_files < <(find install/bin -maxdepth 1 -type f -exec sh -c 'file "$1" | grep -q ELF' sh {} \; -print) + max_glibc=$(objdump -T "${elf_files[@]}" \ + | sed -n 's/.*(GLIBC_\([0-9][0-9.]*\)).*/\1/p' \ + | sort -V \ + | tail -n 1) + echo "Maximum referenced GLIBC version: ${max_glibc:-none}" + if [[ -n "${max_glibc}" ]] && [[ "$(printf '%s\n%s\n' "2.17" "${max_glibc}" | sort -V | tail -n 1)" != "2.17" ]]; then + echo "Linux package references GLIBC_${max_glibc}; expected no symbols newer than GLIBC_2.17." >&2 + exit 1 + fi + + - name: Run PR smoke tests on installed binary (Linux) + if: github.event_name == 'pull_request' && runner.os == 'Linux' + working-directory: localizer/src/tests + shell: bash + run: | + set -e + python -m pip install pytest pandas + python -m pytest -v test_ui_metric_command_builder.py test_quick_cli.py -k "help or version_information or no_subcommand" + - name: Run pytest on installed binary (Linux/macOS) - if: runner.os != 'Windows' + if: startsWith(github.ref, 'refs/tags/') && runner.os != 'Windows' working-directory: localizer/src/tests shell: bash run: | @@ -171,8 +231,16 @@ jobs: shell: pwsh run: cmake --install build --config Release + - name: Run PR smoke tests on installed binary (Windows) + if: github.event_name == 'pull_request' && runner.os == 'Windows' + working-directory: localizer/src/tests + shell: pwsh + run: | + python -m pip install pytest pandas + python -m pytest -v test_ui_metric_command_builder.py test_quick_cli.py -k "help or version_information or no_subcommand" + - name: Run pytest on installed binary (Windows) - if: runner.os == 'Windows' + if: startsWith(github.ref, 'refs/tags/') && runner.os == 'Windows' working-directory: localizer/src/tests shell: pwsh run: | @@ -180,7 +248,7 @@ jobs: python -m pytest -v - name: Copy install/bin into localizer/cpp (Linux/macOS) - if: runner.os != 'Windows' + if: startsWith(github.ref, 'refs/tags/') && runner.os != 'Windows' shell: bash working-directory: localizer/src run: | @@ -189,7 +257,7 @@ jobs: cp -a install/bin/. "$GITHUB_WORKSPACE/localizer/cpp/" - name: Copy install/bin into localizer/cpp (Windows) - if: runner.os == 'Windows' + if: startsWith(github.ref, 'refs/tags/') && runner.os == 'Windows' shell: pwsh working-directory: localizer/src run: | @@ -197,6 +265,7 @@ jobs: Copy-Item -Path install/bin/* -Destination "$env:GITHUB_WORKSPACE/localizer/cpp/" -Recurse -Force - name: Determine package metadata + if: startsWith(github.ref, 'refs/tags/') id: package-metadata shell: bash env: @@ -244,7 +313,7 @@ jobs: echo "platform=${platform}" >> "$GITHUB_OUTPUT" - name: Assemble standalone core package (Linux/macOS) - if: runner.os != 'Windows' + if: startsWith(github.ref, 'refs/tags/') && runner.os != 'Windows' shell: bash working-directory: localizer/src run: | @@ -257,7 +326,7 @@ jobs: cp "$GITHUB_WORKSPACE/LICENSE.md" "$package_dir/LICENSE.md" - name: Assemble standalone core package (Windows) - if: runner.os == 'Windows' + if: startsWith(github.ref, 'refs/tags/') && runner.os == 'Windows' shell: pwsh working-directory: localizer/src run: | @@ -273,6 +342,7 @@ jobs: Copy-Item -Path (Join-Path $env:GITHUB_WORKSPACE 'LICENSE.md') -Destination (Join-Path $packageDir 'LICENSE.md') -Force - name: Create distributable zip (standalone core) + if: startsWith(github.ref, 'refs/tags/') uses: thedoctor0/zip-release@0.7.6 with: type: 'zip' @@ -280,6 +350,7 @@ jobs: path: DCCCcore-${{ env.PACKAGE_VERSION }}-${{ env.PACKAGE_PLATFORM }} - name: Upload standalone core artifact + if: startsWith(github.ref, 'refs/tags/') uses: actions/upload-artifact@v4 with: name: DCCCcore-${{ env.PACKAGE_VERSION }}-${{ env.PACKAGE_PLATFORM }} @@ -287,7 +358,7 @@ jobs: if-no-files-found: error - name: Clean packaging workspace (Linux/macOS) - if: runner.os != 'Windows' + if: startsWith(github.ref, 'refs/tags/') && runner.os != 'Windows' shell: bash run: | set -euo pipefail @@ -304,7 +375,7 @@ jobs: rm -f .gitmodules - name: Clean packaging workspace (Windows) - if: runner.os == 'Windows' + if: startsWith(github.ref, 'refs/tags/') && runner.os == 'Windows' shell: pwsh run: | $pathsToRemove = @( @@ -330,6 +401,7 @@ jobs: } - name: Create distributable zip (entire project) + if: startsWith(github.ref, 'refs/tags/') uses: thedoctor0/zip-release@0.7.6 with: type: 'zip' @@ -337,6 +409,7 @@ jobs: path: . - name: Upload artifact + if: startsWith(github.ref, 'refs/tags/') uses: actions/upload-artifact@v4 with: name: DCCCSlicer-${{ env.PACKAGE_VERSION }}-${{ env.PACKAGE_PLATFORM }} @@ -365,5 +438,3 @@ jobs: generate_release_notes: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - diff --git a/.gitignore b/.gitignore index 4942972..9cce9a7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ localizer/Normalized*.nii localizer/tmp.nii localizer/ref.nii localizer/roi.nii +localizer/templates/fetch_template.ipynb tracked_files.txt *archive* @@ -29,4 +30,7 @@ localizer/cpp .cache/ # code for generating seg.nrrd atlases -localizer/templates/*.ipynb \ No newline at end of file +localizer/templates/*.ipynb + +# codex +.codex diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e6d88e8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,13 @@ +每次递增版本号时,更新以下文件(写路径全名,避免改错同名文件): +- `localizer/src/CMakeLists.txt` + - `project(DCCCcore VERSION X.Y.Z)` 只写纯数字版本。 + - 如果是预发布版本,再同步更新 `DCCCCORE_VERSION_SUFFIX`(例如 `-alpha`)。 +- `localizer/src/conanfile.py` + - `version = "X.Y.Z"` 或 `version = "X.Y.Z-alpha"`,需要与对外发布版本保持一致。 +- `localizer/src/core/config/Version.h` + - 当前仓库会提交这个生成后的头文件,里面的 `SOFTWARE_VERSION` 也要同步更新,避免 `--version` 或批处理日志显示旧版本。 + +补充说明: +- `localizer/src/core/config/Version.h.in` 是模板文件,日常递增版本号时不需要手改具体版本号;它会从 `localizer/src/CMakeLists.txt` 里的完整软件版本变量生成。 +- 仓库根目录的 `CMakeLists.txt` 当前没有单独的项目版本号,不属于版本递增时必须同步的文件。 +- 发布时还要注意 Git tag。`.github/workflows/cross-compile.yml` 会优先用 tag 名 `vX.Y.Z` 生成发布包版本;如果源码版本已经更新但 tag 没跟上,发布产物版本仍可能不一致。 diff --git a/CMakeLists.txt b/CMakeLists.txt index 010b58d..52b5c81 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,10 +4,10 @@ project(DCCCSlicer) #----------------------------------------------------------------------------- # Extension meta-information -set(EXTENSION_HOMEPAGE "https://www.slicer.org/wiki/Documentation/Nightly/Extensions/ACPCLocalizer") +set(EXTENSION_HOMEPAGE "https://github.com/tctco/DCCCSlicer") set(EXTENSION_CATEGORY "Examples") set(EXTENSION_CONTRIBUTORS "Cheng Tang") -set(EXTENSION_DESCRIPTION "This is an example of a simple extension") +set(EXTENSION_DESCRIPTION "An open-source, super-simple, ultra-fast, fully-automated, fairly-accurate and PET-only solution to conduct spatial normalization and semi-quantification for almost any brain PET modalities.") set(EXTENSION_ICONURL "https://www.example.com/Slicer/Extensions/ACPCLocalizer.png") set(EXTENSION_SCREENSHOTURLS "https://www.example.com/Slicer/Extensions/ACPCLocalizer/Screenshots/1.png") set(EXTENSION_DEPENDS "NA") # Specified as a list or "NA" if no dependencies diff --git a/README.md b/README.md index 419c369..703ceb0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # DCCCSlicer -📢📢📢To use DCCCSlicer, you need to download from our [Release Page](https://github.com/tctco/DCCCSlicer/releases). +📢📢📢To use DCCCSlicer, download the packaged binaries from our [Release Page](https://github.com/tctco/DCCCSlicer/releases).

logo @@ -11,6 +11,8 @@ An open-source, super-simple, ultra-fast, fully-automated, fairly-accurate and P > Abeta, tau, FDG, DAT, MET, synaptic density... you name it! +> DCCCSlicer is currently only available on Windows. To use it on other platforms, you may need to recompile it from the [source](https://github.com/tctco/Beyond-Centiloid-code). + Use DCCC to calculate Centiloid and CenTauRz. https://github.com/user-attachments/assets/7ba5346a-3214-4a92-80f3-370f4c46f29c @@ -21,8 +23,10 @@ https://github.com/user-attachments/assets/680490d2-ebec-4846-871c-98fdc383b513 ## News 🎉🎉🎉 -- 20260208: Our paper entitled _Decoupling Alzheimer’s Disease Pathology in PET with Improved Clinical Relevance via Interpretable Adversarial Decomposition Learning_ has just been accepted by _Radiology_! You can try out the `ADAD` score in our 3DSlicer plugin! -- 20260408: Our paper is published online by [Radiolody](https://pubs.rsna.org/doi/10.1148/radiol.252321)! +- 20260208: Our paper entitled _Decoupling Alzheimer’s Disease Pathology in PET with Improved Clinical Relevance via Interpretable Adversarial Decomposition Learning_ was accepted by _Radiology_. +- 20260408: The paper is now published online by [Radiology](https://pubs.rsna.org/doi/10.1148/radiol.252321). +- 20260610: Our paper entitled _Cascaded Deep Learning enables multimodal brain PET spatial normalization and quantification for Alzheimer’s disease_ was accepted by _NeuroImage_. +- 20260613: The paper is now published online by [NeuroImage](https://www.sciencedirect.com/science/article/pii/S105381192600354X). ## Quality control @@ -39,7 +43,7 @@ It’s always a good idea to manually verify that DCCC has performed spatial nor ## Metrics -Relative SUV (ratio) error and time consumption on the Centiloid/CenTauRz projects. +Relative SUVr error and time consumption on the Centiloid/CenTauRz projects. $$ \Delta \mathrm{SUVr} \ \\% = \frac{|\mathrm{SUVr_{GT} - \mathrm{SUVr_{Method}}|}}{\mathrm{SUVr_{GT}}}\times 100\\% @@ -72,15 +76,11 @@ Metrics: - `CenTauR`: Leuzy A, Raket LL, Villemagne VL, Klein G, Tonietto M, Olafson E, et al. Harmonizing tau positron emission tomography in Alzheimer’s disease: The CenTauR scale and the joint propagation model. Alzheimer’s & Dementia. 2024;20(9):5833–48. - `CenTauRz`: Villemagne VL, Leuzy A, Bohorquez SS, Bullich S, Shimada H, Rowe CC, et al. CenTauR: Toward a universal scale and masks for standardizing tau imaging studies. Alzheimer’s & Dementia: Diagnosis, Assessment & Disease Monitoring. 2023;15(3):e12454. - `Fill States`: Doering E, Hoenig MC, Giehl K, et al. “Fill States”: PET-derived Markers of the Spatial Extent of Alzheimer Disease Pathology. Radiology. 2025;314(3):e241482. doi:10.1148/radiol.241482 - - - - -- [ ] `Abeta load / AmyloidIQ` (working on 🚧, the following publications used the same algorithm) : +- `Abeta load / AmyloidIQ`: - Whittington A, Sharp DJ, Gunn RN. Spatiotemporal Distribution of β-Amyloid in Alzheimer Disease Is the Result of Heterogeneous Regional Carrying Capacities. Journal of Nuclear Medicine. 2018 May 1;59(5):822–7. - - Whittington A, Gunn RN. Amyloid Load: A More Sensitive Biomarker for Amyloid Imaging. Journal of Nuclear Medicine. 2019 Apr 1;60(4):536–40. - - Rizzo G, Whittington A, Hesterman J, Gunn RN. AmyloidIQ: An advanced analytical algorithm to quantify amyloid-PET [18F]NAV4694 scans. Alzheimer’s & Dementia. 2020 Dec;16(S4):e043823. -- [ ] `Abeta index` (working on 🚧, the following publications used the same algorithm) : + - Whittington A, Gunn RN. Amyloid Load: A More Sensitive Biomarker for Amyloid Imaging. Journal of Nuclear Medicine. 2019 Apr 1;60(4):536–40. + - Rizzo G, Whittington A, Hesterman J, Gunn RN. AmyloidIQ: An advanced analytical algorithm to quantify amyloid-PET [18F]NAV4694 scans. Alzheimer’s & Dementia. 2020 Dec;16(S4):e043823. +- `Abeta index`: - Lilja J, Leuzy A, Chiotis K, Savitcheva I, Sörensen J, Nordberg A. Spatial normalization of 18F-flutemetamol PET images using an adaptive principal-component template. Journal of Nuclear Medicine. 2019;60(2):285–291. - Leuzy A, Lilja J, Buckley CJ, Ossenkoppele R, Palmqvist S, Battle M, et al. Derivation and utility of an Aβ-PET pathology accumulation index to estimate Aβ load. Neurology. 2020;95(21):e2834–e2844. - `ADAD`: not published yet @@ -97,7 +97,7 @@ Spatial normalization algorithms: ## Command-line interface -For users who prefer running the core calculator from the command line (including batch processing and advanced options), please refer to the standalone [CLI documentation](./localizer/src/README.md). Release assets now publish the headless package separately as `DCCCcore--.zip`, alongside the full `DCCCSlicer` extension package. +For users who prefer running the core calculator from the command line (including batch processing and advanced options), please refer to the standalone [CLI documentation](./localizer/src/README.md). Release assets also publish the headless package separately as `DCCCcore--.zip`, alongside the full `DCCCSlicer` extension package. ## TODO @@ -108,7 +108,7 @@ For users who prefer running the core calculator from the command line (includin - [x] Added support for Fast and Accurate Amyloid Brain PET Quantification Without MRI Using Deep Neural Networks - [ ] Improve the UI. -> Check our reproduction reports on [fill states](./docs/Fill-states.md), [Abeta load](./docs/Abeta-load.md) and [Abeta index](./docs/Abeta-index.md)! +> Check our reproduction reports on [Fill States](./docs/Fill-states.md), [Abeta load](./docs/Abeta-load.md), and [Abeta index](./docs/Abeta-index.md). ## Acknowledgements @@ -143,22 +143,32 @@ We sincerely thank the passionate and outstanding users and contributors of DCCC ## Citation -If you find this repo helpful for your work, please cite +If you find this repository helpful for your work, please cite: -```bib +```bibtex @article{doi:10.1148/radiol.252321, -author = {Tang, Cheng and Sun, Xun and Tang, Anqi and Ruan, Weiwei and Liu, Fang and Fang, Hanyi and Gai, Yongkang and Liang, Zhihou and Su, Ying and Wang, Xinggang and Lan, Xiaoli}, -title = {Decoupling Alzheimer Disease Pathologic Abnormalities at PET with Improved Clinical Relevance by Interpretable Adversarial Decomposition Learning}, -journal = {Radiology}, -volume = {319}, -number = {1}, -pages = {e252321}, + author = {Tang, Cheng and Sun, Xun and Tang, Anqi and Ruan, Weiwei and Liu, Fang and Fang, Hanyi and Gai, Yongkang and Liang, Zhihou and Su, Ying and Wang, Xinggang and Lan, Xiaoli}, + title = {Decoupling Alzheimer Disease Pathologic Abnormalities at PET with Improved Clinical Relevance by Interpretable Adversarial Decomposition Learning}, + journal = {Radiology}, + volume = {319}, + number = {1}, + pages = {e252321}, + year = {2026}, + doi = {10.1148/radiol.252321}, + note = {PMID: 41944723}, + url = {https://doi.org/10.1148/radiol.252321} +} + +@article{TANG2026122039, +title = {Cascaded Deep Learning enables multimodal brain PET spatial normalization and quantification for Alzheimer’s disease}, +journal = {NeuroImage}, +pages = {122039}, year = {2026}, -doi = {10.1148/radiol.252321}, -note ={PMID: 41944723}, -URL = {https://doi.org/10.1148/radiol.252321}, -eprint = { https://doi.org/10.1148/radiol.252321}, -abstract = { Background Template-based PET metrics quantify Alzheimer disease (AD) amyloid-β (Aβ) and tau burden but compress whole-brain data into a single scalar, overlooking disease heterogeneity and sometimes causing imaging-clinical discordance. Artificial intelligence (AI) approaches capture richer patterns but often lack biologic interpretability. Purpose To develop and validate an interpretable deep-learning framework that separates AD-specific abnormalities from physiologic uptake using pathophysiologic constraints, generating a clinically meaningful AI biomarker. Materials and Methods In this retrospective study, Aβ and tau PET scans from the Alzheimer’s Disease Neuroimaging Initiative, Australian Imaging Biomarkers and Lifestyle study, Global Alzheimer’s Association Interactive Network, and the authors’ center were analyzed. An adversarial decomposition learning (ADL) network generated voxel-level pathologic maps and an AD adversarial decomposition (ADAD) score. Discriminatory performance for clinical AD versus cognitively normal individuals was evaluated using the area under the curve (AUC). Clinical relevance was assessed with cognitive, hippocampal volume, cerebrospinal fluid (CSF), and neuropathologic measures using longitudinal mixed-effects models and Spearman correlations. Results The study included 7457 Aβ PET scans from 3595 patients (median age, 71.4 years; IQR, 65.7–77.0 years; 1637 female patients) and 1894 tau PET scans from 1127 patients (median age, 72.0 years; IQR, 66.9–78.5 years; 545 female patients). External testing AUCs were 0.94 (95\% CI: 0.89, 0.98) for Aβ and 0.98 (95\% CI: 0.95, 1.00) for tau. ADL generated interpretable pathologic attribution maps that correlated with expert rankings (Aβ and tau, Spearman ρ = 0.79 and 0.63, respectively). Although Centiloid and CenTauRz showed numerically higher correlations with postmortem neuropathologic structure and stronger associations with CSF biomarkers, the ADAD score demonstrated independent baseline and longitudinal associations with cognitive outcomes and hippocampal atrophy after adjustment. Conclusion Pathophysiologic-constrained ADL provided interpretable, personalized pathologic maps and an AI-derived ADAD score that more closely linked PET pathologic abnormalities with multimodal clinical measures. © RSNA, 2026 Supplemental material is available for this article. } +issn = {1053-8119}, +doi = {https://doi.org/10.1016/j.neuroimage.2026.122039}, +url = {https://www.sciencedirect.com/science/article/pii/S105381192600354X}, +author = {Cheng Tang and Anqi Tang and Mengyu Wan and Chunyan Li and Weiwei Ruan and Fang Liu and Hanyi Fang and Yongkang Gai and Dawei Jiang and Wanye Zhou and Xiaoli Lan and Xun Sun}, +keywords = {Alzheimer’s Disease, Brain PET Imaging, Spatial Normalization, Deep Learning}, } ``` diff --git a/docker-compose.core.yml b/docker-compose.core.yml new file mode 100644 index 0000000..04e5116 --- /dev/null +++ b/docker-compose.core.yml @@ -0,0 +1,42 @@ +services: + dccc-core: + build: + context: . + dockerfile: docker/Dockerfile.core + args: + USER_UID: "${UID:-1000}" + USER_GID: "${GID:-1000}" + image: dccc-core-dev:latest + working_dir: /workspace/localizer/src + volumes: + - .:/workspace + - dccc-conan-cache:/home/dev/.conan2 + - dccc-cmake-build:/workspace/localizer/src/build + tty: true + stdin_open: true + + + dccc-core-legacy: + build: + context: . + dockerfile: docker/Dockerfile.core.legacy + args: + USER_UID: "${UID:-1000}" + USER_GID: "${GID:-1000}" + image: dccc-core-dev:legacy-glibc217 + working_dir: /workspace/localizer/src + volumes: + - .:/workspace + - dccc-conan-cache-legacy:/home/dev/.conan2 + - dccc-cmake-build-legacy:/workspace/localizer/src/build + environment: + CONAN_CPPSTD: "gnu17" + CONAN_BUILD_ARGS: "--build=missing --build=b2/* --build=m4/* --build=autoconf/* --build=automake/* --build=libtool/* --build=pkgconf/*" + tty: true + stdin_open: true + +volumes: + dccc-conan-cache: + dccc-cmake-build: + dccc-conan-cache-legacy: + dccc-cmake-build-legacy: diff --git a/docker/Dockerfile.core b/docker/Dockerfile.core new file mode 100644 index 0000000..01aaf4a --- /dev/null +++ b/docker/Dockerfile.core @@ -0,0 +1,43 @@ +FROM ubuntu:22.04 + +ARG DEBIAN_FRONTEND=noninteractive +ARG USERNAME=dev +ARG USER_UID=1000 +ARG USER_GID=1000 + +ENV TZ=Etc/UTC \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + CONAN_HOME=/home/${USERNAME}/.conan2 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + cmake \ + curl \ + git \ + ninja-build \ + pkg-config \ + python3 \ + python3-pip \ + python3-venv \ + sudo \ + unzip \ + zlib1g-dev \ + && rm -rf /var/lib/apt/lists/* + +RUN python3 -m pip install --no-cache-dir --default-timeout=120 --retries=10 \ + "conan>=2.0,<3" \ + pandas \ + pytest + +RUN groupadd --gid ${USER_GID} ${USERNAME} \ + && useradd --uid ${USER_UID} --gid ${USER_GID} -m ${USERNAME} -s /bin/bash \ + && echo "${USERNAME} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/${USERNAME} \ + && chmod 0440 /etc/sudoers.d/${USERNAME} + +USER ${USERNAME} +WORKDIR /workspace/localizer/src + +RUN conan profile detect --force + +CMD ["/bin/bash"] diff --git a/docker/Dockerfile.core.legacy b/docker/Dockerfile.core.legacy new file mode 100644 index 0000000..3d1a2db --- /dev/null +++ b/docker/Dockerfile.core.legacy @@ -0,0 +1,55 @@ +FROM quay.io/pypa/manylinux2014_x86_64 + +ARG USERNAME=dev +ARG USER_UID=1000 +ARG USER_GID=1000 + +ENV TZ=Etc/UTC \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + CONAN_HOME=/home/${USERNAME}/.conan2 \ + PATH=/opt/python/cp311-cp311/bin:/opt/rh/devtoolset-10/root/usr/bin:$PATH \ + LD_LIBRARY_PATH=/opt/rh/devtoolset-10/root/usr/lib64:/opt/rh/devtoolset-10/root/usr/lib \ + CC=gcc \ + CXX=g++ + +# Conan has to build several dependencies from source in the legacy glibc +# container. Install a small source-build baseline instead of discovering +# missing autotools/Perl pieces one CI failure at a time. OpenSSL's Configure +# uses Perl core modules such as IPC::Cmd and Time::Piece, and libcurl's +# autoreconf path needs GNU autotools utilities including m4. +RUN yum install -y \ + autoconf \ + automake \ + libtool \ + m4 \ + make \ + patch \ + perl-core \ + pkgconfig \ + sudo \ + which \ + zlib-devel \ + && yum clean all + +RUN perl -MIPC::Cmd -MTime::Piece -e 1 \ + && m4 --version \ + && autoreconf --version + +RUN python -m pip install --no-cache-dir --default-timeout=120 --retries=10 \ + "cmake>=3.27,<4" \ + "conan>=2.0,<3" \ + ninja \ + pandas \ + pytest + +RUN groupadd --gid ${USER_GID} ${USERNAME} \ + && useradd --uid ${USER_UID} --gid ${USER_GID} -m ${USERNAME} -s /bin/bash \ + && echo "${USERNAME} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/${USERNAME} \ + && chmod 0440 /etc/sudoers.d/${USERNAME} + +USER ${USERNAME} +WORKDIR /workspace/localizer/src + +RUN conan profile detect --force + +CMD ["/bin/bash"] diff --git a/docs/CenTauRz.md b/docs/CenTauRz.md new file mode 100644 index 0000000..36cdee4 --- /dev/null +++ b/docs/CenTauRz.md @@ -0,0 +1,12 @@ +# CenTauRz + +## Conversion equations from Statistical Parametric Mapping standardized uptake value ratios to CenTauRz. + +| Tracer | Universal mask | Mesial temporal | Meta temporal | Temporo parietal | Frontal | +|--------|----------------|-----------------|----------------|-----------------|---------| +| 18F-RO948 | 13.05x–15.57 | 11.76x–13.08 | 13.16x–16.19 | 13.05x–15.62 | 12.61x–13.45 | +| 18F-FTP | 13.63x–15.85 | 10.42x–12.11 | 12.95x–15.37 | 13.75x–15.92 | 11.61x–13.01 | +| 18F-MK6240 | 10.08x–10.06 | 7.28x–7.01 | 9.36x–10.6 | 9.98x–10.15 | 10.05x–8.91 | +| 18F-GTP1 | 10.67x–11.92 | 7.88x–8.75 | 9.60x–11.10 | 10.84x–12.27 | 9.41x–9.71 | +| 18F-PM-PBB3 | 16.73x–15.34 | 7.97x–7.83 | 11.78x–11.21 | 16.16x–14.68 | 15.7x–13.18 | +| 18F-PI2620 | 8.45x–9.61 | 6.03x–6.83 | 7.78x–9.33 | 8.21x–9.52 | 9.07x–9.01 | \ No newline at end of file diff --git a/docs/developer_guide.md b/docs/developer_guide.md index f8a43e6..4a96c0a 100644 --- a/docs/developer_guide.md +++ b/docs/developer_guide.md @@ -1,582 +1,385 @@ -## Developer Guide: Extending the Deep Cascaded Cerebral Calculator - -This document is intended for contributors and advanced users who want to extend the **Deep Cascaded Cerebral Calculator** (DCCCSlicer core) with new semi‑quantitative PET biomarkers (\"metrics\"), or understand how the existing ones (Centiloid, CenTauR/CenTauRz, Fill‑states, SUVr) are implemented. - -It consolidates and expands the previous “Developer Guide” section from `localizer/src/README.md` and adds a concrete example based on the new `FillStates` metric. - ---- - -## 1. High‑Level Architecture - -At a high level, the system is structured as follows: - -```mermaid -graph TD - subgraph CLI - Main[main.cpp] --> Cmds[cli/Commands] - Main --> Opts[cli/Options] - end - - subgraph Config - IConfiguration - Configuration - end - - subgraph Pipeline - ProcessingPipeline - BatchProcessor - end - - subgraph Normalization - ISpatialNormalizer - SpatialNormalizerFactory - RigidVoxelMorphNormalizer - subgraph RegistrationPipeline - ImagePreprocessor - RigidRegistrationEngine - NonlinearRegistrationEngine - end - end - - subgraph Metrics - IMetricCalculator - MetricCalculatorFactory - SUVrCalculator - CentiloidCalculator - CenTauRCalculator - CenTauRzCalculator - FillStatesCalculator - end - - subgraph Decoupling - Decoupler - DecoupledResult - end - - Input[NIfTI PET images] -->|single file| ProcessingPipeline - Input -->|batch| BatchProcessor - - Cmds --> ProcessingPipeline - Cmds --> BatchProcessor - Opts --> Cmds - - Configuration --> ProcessingPipeline - IConfiguration --> Configuration - - ProcessingPipeline --> SpatialNormalizerFactory - SpatialNormalizerFactory --> RigidVoxelMorphNormalizer - RigidVoxelMorphNormalizer --> RegistrationPipeline - RigidRegistrationEngine --> ONNXRuntime[ONNX Runtime] - NonlinearRegistrationEngine --> ONNXRuntime - - ProcessingPipeline --> MetricCalculatorFactory - MetricCalculatorFactory --> SUVrCalculator - MetricCalculatorFactory --> CentiloidCalculator - MetricCalculatorFactory --> CenTauRCalculator - MetricCalculatorFactory --> CenTauRzCalculator - MetricCalculatorFactory --> FillStatesCalculator - - ProcessingPipeline --> Decoupler - Decoupler --> ONNXRuntime - Decoupler --> DecoupledResult - - ProcessingPipeline --> OutputSingle[Output NIfTI and Metric Results] - BatchProcessor --> OutputBatch[Processed NIfTI files, results.csv, batch_info.txt] - - Common[utils/common.h - ITK and ONNX helpers] --> ProcessingPipeline - Common --> RigidVoxelMorphNormalizer - Common --> RigidRegistrationEngine - Common --> NonlinearRegistrationEngine - Common --> SUVrCalculator - TemplatesMasks[Templates and Masks via config] --> RigidVoxelMorphNormalizer - TemplatesMasks --> SUVrCalculator - TemplatesMasks --> Decoupler -``` +# Developer Guide -Key components: +This guide describes the current `localizer/src` architecture after the metric refactor. The main rule is simple: -- **Interfaces** (`interfaces/`): contracts for calculators (`IMetricCalculator`), spatial normalizers (`ISpatialNormalizer`), and configuration (`IConfiguration`). -- **Calculators** (`calculators/`): implementations of specific metrics (e.g., `Centiloid`, `CenTauR`, `CenTauRz`, `SUVr`, `FillStates`). -- **Factories** (`factories/`): `MetricCalculatorFactory` and `SpatialNormalizerFactory` manage construction and registration of calculators/normalizers. -- **Pipeline** (`pipeline/`): `ProcessingPipeline` orchestrates I/O, spatial normalization, metric calculation, and decoupling. -- **Decoupling** (`decouplers/`): deep‑learning–based AD‑related pathology extraction. -- **Utilities** (`utils/common.*`): ITK image I/O, resampling, mean computation, path helpers, etc. +- `core/` provides shared capabilities +- `metrics/` owns metric workflows +- `spatialNormalizations/` exposes normalization-only CLIs ---- +There is no longer a global metric pipeline in `core`. -## 2. Standard Workflow for Adding a New Metric +## Architecture -New metrics in DCCCSlicer typically follow a standard pattern: +### `core/` -1. Define a new `IMetricCalculator` implementation under `localizer/src/calculators/`. -2. Register it in `MetricCalculatorFactory`. -3. Add configuration entries (masks, tracer parameters, etc.) in `assets/configs/config.toml`. -4. Add optional command‑line support (new subcommand) via `cli/Options` and `cli/Commands`. -5. Add tests (unit + CLI/batch integration). +`core/` is the foundation layer. It should not know how a specific metric works. -Below is a generic step‑by‑step template you can follow. +Key areas: -### 2.1. Implement the Calculator Interface +- `core/config/` + - TOML loading + - executable-relative path resolution + - default configuration values +- `core/common/` + - NIfTI IO + - image operations + - filesystem/path helpers + - logging helpers + - normalization contracts +- `core/services/` + - `SpatialNormalizationService` + - `FileService` +- `core/di/` + - `ServiceContainer` + - `buildCoreContainer(...)` +- `core/normalizers/` + - rigid + deformable normalization implementation details -Create a new calculator class deriving from `IMetricCalculator`: +`core/` should not contain: -```cpp -// localizer/src/calculators/MyNewMetricCalculator.h -#pragma once -#include "../interfaces/IMetricCalculator.h" -#include "../interfaces/IConfiguration.h" - -class MyNewMetricCalculator : public IMarkerCalculator { -public: - explicit MyNewMetricCalculator(ConfigurationPtr config); - ~MyNewMetricCalculator() override = default; - - MetricResult calculate(ImageType::Pointer spatialNormalizedImage) override; - std::string getName() const override; - std::vector getSupportedTracers() const override; - -private: - ConfigurationPtr config_; - - struct TracerParams { - float slope; - float intercept; - // Add additional parameters as needed - }; - - std::map getTracerParameters() const; -}; -``` +- metric registries for calculation dispatch +- metric logic abstractions +- metric-specific workflows +- metric CLI abstractions +- a global `PipelineApplication` -In the implementation, you typically: +### `metrics/` -- Load VOI and reference masks via `config_->getMaskPath(...)`. -- Use `SUVrCalculator::calculateSUVr(...)` to compute region‑based SUVr if your metric is derived from SUVr. -- Compute your metric per tracer and populate `MetricResult`. +Each metric is a self-contained module under `localizer/src/metrics//`. -```cpp -// localizer/src/calculators/MyNewMetricCalculator.cpp -#include "MyNewMetricCalculator.h" -#include "SUVrCalculator.h" -#include "../utils/common.h" +Current examples: -MyNewMetricCalculator::MyNewMetricCalculator(ConfigurationPtr config) - : config_(config) {} +- `suvr/` +- `centiloid/` +- `centaur/` +- `centaurz/` +- `fillstates/` +- `abetaload/` +- `abetaindex/` +- `adad/` -MetricResult MyNewMetricCalculator::calculate(ImageType::Pointer spatialNormalizedImage) { - std::string voiMaskPath = config_->getMaskPath("my_metric_voi"); - std::string refMaskPath = config_->getMaskPath("my_metric_ref"); +Each metric should own its own execution path: - double suvr = SUVrCalculator::calculateSUVr(spatialNormalizedImage, voiMaskPath, refMaskPath); +- parse arguments in its CLI +- build a core container +- resolve shared services +- decide whether to normalize +- call its calculator +- format outputs - MetricResult result; - result.metricName = "MyNewMetric"; - result.suvr = suvr; +### `metrics/shared/` - auto tracerParams = getTracerParameters(); - for (const auto& [tracerName, params] : tracerParams) { - float value = suvr * params.slope + params.intercept; - result.tracerValues[tracerName] = value; - } +This folder is for metric-facing shared utilities that do not belong in `core/`. - return result; -} +Current shared pieces: -std::string MyNewMetricCalculator::getName() const { - return "MyNewMetric"; -} +- `IMetricCLI.h` +- `MetricRegistry.*` +- `MetricTypes.h` +- `MetricRunResult.h` +- `SingleRunner.h` +- `BatchRunner.h` +- `BatchLogging.*` +- `DebugPathHelpers.h` -std::vector MyNewMetricCalculator::getSupportedTracers() const { - return {"TRACER1", "TRACER2"}; -} +Use `metrics/shared/` for cross-metric helpers. Do not push metric workflow abstractions back into `core/`. -std::map -MyNewMetricCalculator::getTracerParameters() const { - std::map params; - for (const auto& tracer : getSupportedTracers()) { - std::string key = Common::toLower(tracer); - TracerParams tp; - tp.slope = config_->getFloat("mynewmetric.tracers." + key + ".slope"); - tp.intercept = config_->getFloat("mynewmetric.tracers." + key + ".intercept"); - params[tracer] = tp; - } - return params; -} -``` +### `spatialNormalizations/` -### 2.2. Register the Calculator in `MetricCalculatorFactory` +These commands expose normalization without metric calculation: -Add a new enumerator and registration branch: +- `normalize` +- `adni-pet-core` -```cpp -// localizer/src/factories/MetricCalculatorFactory.h -enum class CalculatorType { - CENTILOID, - CENTAUR, - CENTAURZ, - SUVR, - FILL_STATES, - MY_NEW_METRIC, // <- your new type -}; -``` +They directly use `ISpatialNormalizationService` and `IFileService`. They do not go through a metric pipeline. -```cpp -// localizer/src/factories/MetricCalculatorFactory.cpp -#include "../calculators/MyNewMetricCalculator.h" - -MetricCalculatorPtr MetricCalculatorFactory::create(CalculatorType type, ConfigurationPtr config) { - switch (type) { - case CalculatorType::CENTILOID: - return std::make_shared(config); - case : // other existing types ... - case CalculatorType::MY_NEW_METRIC: - return std::make_shared(config); - default: - throw std::invalid_argument("Unknown metric calculator type"); - } -} +## Runtime Flow -MetricCalculatorFactory::CalculatorType -MetricCalculatorFactory::stringToType(const std::string& typeName) { - std::string lowerName = typeName; - std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), ::tolower); - - if (lowerName == "suvr") { - return CalculatorType::SUVR; - } else if (lowerName == "centiloid") { - return CalculatorType::CENTILOID; - } else if (lowerName == "centaur") { - return CalculatorType::CENTAUR; - } else if (lowerName == "centaurz") { - return CalculatorType::CENTAURZ; - } else if (lowerName == "fillstates") { - return CalculatorType::FILL_STATES; - } else if (lowerName == "mynewmetric") { - return CalculatorType::MY_NEW_METRIC; - } - - throw std::invalid_argument("Unsupported metric calculator type: " + typeName); -} +The executable entrypoint is [main.cpp](../localizer/src/main.cpp). -std::vector MetricCalculatorFactory::getAvailableTypes() { - return {"suvr", "centiloid", "centaur", "centaurz", "fillstates", "mynewmetric"}; -} -``` +At startup: -### 2.3. Wire into the Processing Pipeline +1. `main.cpp` builds metric CLIs from `Pipeline::Metrics::buildCLIModules()` +2. `main.cpp` builds normalization CLIs from `Pipeline::SpatialNormalization::buildCLIModules()` +3. the selected subcommand executes its own module -By default, the `ProcessingPipeline` uses `MetricCalculatorFactory::createSelected(selectedMetric, config_)` to construct the requested metric(s) and then calls `calculate`: +For a metric command, the flow is: -```cpp -// localizer/src/pipeline/ProcessingPipeline.h -struct ProcessingOptions { - bool skipRegistration = false; - bool useIterativeRigid = false; - bool useManualFOV = false; - bool enableADNIStyle = false; - std::string decoupleModality = ""; // "abeta", "tau" or empty - int maxIterations = 5; - float convergenceThreshold = 2.0f; - bool enableDebugOutput = false; - std::string debugOutputBasePath = ""; - std::string selected output image path - std::string selectedMetric = ""; // "suvr", "centiloid", "centaur", "centaurz", "fillstates", ... - std::string selectedMetricTracer = ""; // for tracer-dependent metrics (e.g. fill-states) -}; - -std::vector calculateMetrics( - ImageType::Pointer spatiallyNormalizedImage, - const ProcessingOptions& options); -``` +1. CLI parses arguments into a metric-specific options struct +2. CLI calls `buildCoreContainer(...)` +3. metric service resolves: + - `IConfiguration` + - `ISpatialNormalizationService` + - `IFileService` +4. metric service runs single-file or batch workflow +5. calculator performs the actual metric computation -If your metric is not tracer‑dependent (like Centiloid or CenTauR), you typically do **not** use `selectedMetricTracer`; you just set `options.selectedMetric` in `cli/Commands.cpp` and leave `selectedMetricTracer` empty. +In other words: + +- `main.cpp` dispatches commands +- `core` provides capabilities +- `metric service` owns workflow +- `metric calculator` owns computation -If your metric *is* tracer‑dependent (e.g., Fill‑states), you can use `selectedMetricTracer` inside your calculator wiring to pass the user‑specified tracer choice. For example: +## Current Module Shape -```cpp -// localizer/src/pipeline/ProcessingPipeline.cpp -std::vector ProcessingPipeline::calculateMetrics( - ImageType::Pointer spatiallyNormalizedImage, - const ProcessingOptions& options) { - std::vector results; - - auto calculators = MetricCalculatorFactory::createSelected(options.selectedMetric, config_); - - for (auto& calculator : calculators) { - // Example: inject tracer information for a custom metric - if (Common::toLower(options.selectedMetric) == "mynewmetric") { - if (!options.selectedMetricTrayyour_metric = dynamic_ca my_metric->setTracer(options.selectedMetricTracer); - } - } - - MetricResult r = calculator-> calculate(spatialNormalizedImage); - results.push_back(r); - } - - return results; -} +The preferred metric shape is: + +```text +metrics// + CLI.h + CLI.cpp + Service.h + Service.cpp + Calculator.h + Calculator.cpp + Module.h + Module.cpp ``` -### 2.4. Update Configuration (`assets/configs/config*.toml`) +Responsibilities: -Typical configuration entries for a new SUVr‑derived metric: +- `CLI` + - declares subcommand name + - defines CLI arguments + - builds options + - creates the core container + - calls the service +- `Service` + - validates metric-specific runtime requirements + - builds normalization requests + - chooses single vs batch execution + - saves normalized output + - prepares calculator inputs from config/assets +- `Calculator` + - pure metric computation + - should not parse CLI flags + - should not bootstrap services +- `Module` + - registers the CLI with `MetricRegistry` -```toml -[masks] -my_metric_voi = "assets/nii/my_metric_voi.nii" -my_metric_ref = "assets/nii/my_metric_ref.nii" +If a metric is very small, `Service` and `Calculator` may look close in size. That is acceptable. The important split is: -[mynewmetric.tracers.tracer1] -slope = 1.0 -intercept = 0.0 +- workflow in `Service` +- numeric/image computation in `Calculator` -[mynewmetric.tracers.tracer2] -slope = 1.2 -intercept = -0.5 +## Registry and Discovery -[suvr.regions.mynewmetric] -voi_mask = "my_metric_voi" -ref_mask = "my_metric_ref" -``` +Metric discovery is handled in: -The existing metrics use similar sections: +- [metrics/shared/MetricRegistry.h](../localizer/src/metrics/shared/MetricRegistry.h) +- [metrics/ModuleCatalog.cpp](../localizer/src/metrics/ModuleCatalog.cpp) -- `centiloid.tracers.*` – linear transformation parameters for each amyloid tracer. -- `centaur.tracers.*` – percentile mapping parameters. -- `centaurz.tracers.*` – z‑score transformations. -- `fillstates.tracers.*` – per‑tracer mean/std/ROI paths for z‑score maps. +This registry is for CLI/module discovery, not for a core-side metric dispatch pipeline. -### 2.5. CLI Integration (Optional) +To expose a new metric: -To expose your metric as a top‑level subcommand (like `centiloid`, `centaur`, `centaurz`, `fillstates`), update `cli/Options` and `cli/Commands`. +1. implement `createCLI()` in the metric folder +2. implement `registerModule(MetricRegistry&)` +3. add the metric module to `metrics/ModuleCatalog.cpp` -1. **Add a subcommand parser in `main.cpp`:** +## Shared Execution Helpers -```cpp -// main.cpp -argparse::ArgumentParser mymetric_cmd("mymetric"); -mymetric_cmd.add_description("Calculate MyNewMetric"); -addSUVrDerivedMetricArguments(mymetric_cmd); // or a custom addMyMetricArguments(...) +Most metrics should reuse the shared runners: -program.add_subparser(mymetric_cmd); +- `SingleRunner.h` +- `BatchRunner.h` -if (program.is_subcommand_used("mymetric")) { - return executeMyMetricCommand(mymetric_cmd, fullCommand); -} -``` +These helpers standardize: -2. **Add a new command options struct (if needed) and execution function in `cli/Commands.*`:** +- logging +- batch iteration +- debug path derivation +- `results.csv` / `batch_info.txt` -```cpp -// cli/Commands.h -int executeMyMetricCommand(const argparse::ArgumentParser& parser, - const std::string& fullCommand); -``` +Use them when the metric fits the common pattern: + +- normalize +- save normalized output +- calculate metric +- report results + +If a metric has a special flow, it can still own that flow directly inside its service. + +## Configuration Rules + +Default configuration lives in: + +- [localizer/src/assets/configs/config.toml](../localizer/src/assets/configs/config.toml) +- [localizer/src/assets/configs/config.fast_and_acc.toml](../localizer/src/assets/configs/config.fast_and_acc.toml) + +Important rules: + +- add metric-specific templates, masks, and coefficients to the config that actually supports that metric +- do not silently enable a metric under an incompatible normalization strategy +- if a metric depends on a specific normalization regime, validate that explicitly in the service + +Example: + +- `abetaindex` is intentionally enabled only in the standard `config.toml` +- `config.fast_and_acc.toml` does not declare it +- `AbetaIndexService` validates that the metric is enabled and that its templates exist + +That pattern is preferred over silently falling back to defaults. + +## Adding a New Metric + +### 1. Create the metric folder + +Add: + +- `CLI.*` +- `Service.*` +- `Calculator.*` +- `Module.*` + +Start from one of these references: + +- `centiloid/` for a typical derived metric +- `suvr/` for a simple VOI/reference metric +- `fillstates/` for a metric with extra output files +- `abetaindex/` for a config-gated template metric +- `adad/` for a model-driven metric + +### 2. Add the CLI + +The CLI should: + +- inherit `Pipeline::Metrics::IMetricCLI` +- define the subcommand name and description +- configure arguments +- map parser values into a metric-specific options struct +- call `buildCoreContainer(...)` +- call `createService(*container)` + +Look at: + +- [CentiloidCLI.cpp](../localizer/src/metrics/centiloid/CentiloidCLI.cpp) +- [AbetaIndexCLI.cpp](../localizer/src/metrics/abetaindex/AbetaIndexCLI.cpp) + +### 3. Add the service + +The service should: + +- accept shared services through the constructor +- validate configuration and runtime preconditions +- build `SpatialNormalizationRequest` +- save the normalized NIfTI via `IFileService` +- build calculator input +- call `runSingle(...)` or `runBatch(...)` + +Prefer reusing: + +- `Pipeline::Metrics::Shared::runSingle(...)` +- `Pipeline::Metrics::Shared::runBatch(...)` + +### 4. Add the calculator + +The calculator should: + +- accept an explicit `Input` struct +- return `Pipeline::Metrics::MetricResult` +- throw descriptive `std::runtime_error` / `std::invalid_argument` when inputs are invalid + +Keep it focused on the actual metric math or image operation. + +### 5. Register the module + +Implement: ```cpp -// cli/Commands.cpp -int executeMyMetricCommand(const argparse::ArgumentParser& parser, - const std::string& fullCommand) { - SUVrDerivedMetricOptions options = - parseSUVrDerivedMetricOptions(parser, "mynewmetric"); - auto config = loadConfigurationWithLogging(options.configPath, options.enableDebugOutput); - - ProcessingOptions procOptions; - procOptions.skipRegistration = options.skipRegistration; - procOptions.useIterativeRigid = options.useIterativeRigid; - procOptions.useManualFOV = options.useManualFOV; - procOptions.enableDebugOutput = options.enableDebugOutput; - procOptions.debugOutputBasePath = options.debugOutputBasePath; - procOptions.selectedMetric = options.metricType; // "mynewmetric" - - // For tracer-dependent metrics, also set procOptions.selectedMetricTracer - - if (options.batchMode) { - auto processor = [config, procOptions](const std::string& inputPath, - const std::string& outputPath) -> ProcessingResult { - ProcessingPipeline pipeline(config); - return pipeline.process(inputPath, outputPath, procOptions); - }; - - std::cout << "Starting " << options.metricType << " batch processing..." << std::endl; - return BatchProcessor::runBatch(...); - } - - ProcessingPipeline pipeline(config); - std::cout << "Starting " << options.metricType << " calculation: " << options.inputPath << std::endl; - ProcessingResult result = pipeline.process(options.inputPath, options.outputPath, procOptions); - - std::cout << "\n=== " << options.metricType << " Results ===" << std::endl; - for (const auto& mr : result.metricResults) { - std::cout << "Metric: " << mr.metricName << std::endl; - for (const auto& [tracer, value] : mr.tracerValues) { - std::cout << tracer << ": " << value << std::endl; - } - if (options.includeSUVr) { - std::cout << "SUVr: " << mr.suvr << std::endl; - } - } - - std::cout << "Processing completed successfully!" << std::endl; - return EXIT_SUCCESS; +void registerModule(MetricRegistry& registry) { + registry.registerModule({"my_metric", true, &createCLI}); } ``` -If your metric is tracer‑dependent (like `fillstates`), define a dedicated `*CommandOptions` (e.g., `FillStatesCommandOptions`) that adds a `--tracer` argument and maps it into `ProcessingOptions::selectedMetricTracer`. - ---- - -## 3. Example: The `FillStates` Metric - -The `FillStates` metric is a concrete example of a **tracer‑dependent, voxelwise z‑score–based** metric, implemented in: - -- `localizer/src/calculers/FillStatesCalculator.{h,cpp}` -- `localizer/src/factories/MetricCalculatorFactory.*` -- `localizer/src/pipeline/ProcessingPipeline.*` -- `localizer/src/cli/Options.*` -- `localizer/src/cli/Commands.*` - -### 3.1. Concept - -`FillStates` quantifies the proportion of voxels within a predefined meta‑ROI that show abnormal signal (e.g., elevated tau or reduced FDG uptake), expressed as a fraction of the ROI’s voxel count: - -- **FBP / FTP (amyloid/tau)**: voxels with **z‑score > 1.65** are considered “filled”. -- **FDG (neurodegeneration)**: voxels with **z‑score < −1.65** are considered “filled” (hypometabolism). - -The output is: - -- `FillStates` metric value per tracer: \( \text{fraction} = \frac{N_{\text{above/below threshold in ROI}}}{N_{\text{voxels in ROI}}} \) -- A binary **fill_states_map** NIfTI image (0/1 mask) aligned to the normalized PET image, written next to the main normalized output as `_fill_states_map.nii`. - -### 3.2. Calculator Implementation - -The core implementation lives in `FillStatesCalculator`: - -- Constructor stores `ConfigurationPtr config_`. -- `setTracer(const std::string& tracer)` sets the active tracer (\"fbp\", \"fdg\", or \"ftp\"). -- `calculate(ImageType::Pointer spatialNormalizedImage)`: - - Validates that `spatialNormalizedImage` is non‑null and that `tracer_` has been set. - - Uses `IConfiguration` to look up per‑tracer config under `fillstates.tracers.`: - - `fillstates.tracers..mean` - - `fillstates.tracers..std` - - `fillstates.tracers..roi` - - Loads mean, std, and ROI templates with `Common::LoadNii` and resamples them to match the spatially normalized PET (`Common::ResampleToMatch`). - - Loads the appropriate reference region mask from config: - - FBP/FDG: `masks.whole_cerebral` - - FTP: `masks.centaur_ref` - - Computes the mean value within the reference mask (`Common::CalculateMeanInMask`) and divides the PET by this mean to perform intensity normalization. - - For each voxel inside the ROI (mask > 0): - - Computes \( z = \frac{I(x) - \mu(x)}{\sigma(x)} \) (skipping voxels where `σ ≤ 0`). - - Marks voxels as \"filled\" if `z > 1.65` (FBP/FTP) or `z < −1.65` (FDG). - - Populates a new `lastMaskImage_` (0/1 float) of the same size/spacing/origin/direction as the normalized PET. - - Computes the fill fraction and stores it in `MetricResult.tracerValues`, using tracer labels `FBP` / `FDG` / `FTP`. `MetricResult.metricName` is `"FillStates"`, and `suvr` is currently set to `0.0` (reserved for potential future use). - -If any required configuration key is missing, or if the reference region mean is non‑positive, the calculator throws a `std::runtime_error` with a descriptive message (for example, *\"Missing fillstates configuration for tracer 'fdg'. Please set fillstates.tracers.fdg.mean/std/roi in config.\"*). These exceptions are propagated up by the pipeline so that the CLI exits with a non‑zero status instead of reporting success. - -### 3.3. Factory and Pipeline Wiring - -- `MetricCalculatorFactory` has a new `CalculatorType::FILL_STATES` and a corresponding `create` branch: - -```startLine:endLine:localizer/src/factories/MetricCalculatorFactory.cpp -MetricCalculatorPtr MetricCalculatorFactory::create(CalculatorType type, ConfigurationPtr config) { - switch (type) { - case CalculatorType::CENTILOID: - return std::make_shared(config); - case CalculatorType::CENTAUR: - return std::make_shared(config); - case CaseType::CENTAURZ: - return std::make_shared(config); - case CaseType::SUVR: - return std::make_shared(config); - case CaseType::FILL_STATES: - return std::make_shared(config); - default: - throw std::invalid_argument("Unknown metric calculator type"); - } -} -``` +Then add it to [metrics/ModuleCatalog.cpp](../localizer/src/metrics/ModuleCatalog.cpp). -- `MetricCalculatorFactory::stringToType` maps `"fillstates"` to `CalculatorType::FILL_STATES`. -- `ProcessingPipeline::calculateMetrics(...)`: - - Creates the calculator via `MetricCalculatorFactory::createSelected(options.selectedMetric, config_)`. - - If `options.selectedMetric == "fillstates"` and `options.selectedMetricTracer` is non‑empty, it calls `fsCalc->setTracer(options.selectedMetricTracer)` before invoking `calculate`. - - After `calculate` returns, it reads `fsCalc->getLastMaskImage()` into `ProcessingResult.fillStatesMaskImage` and later writes it to `_fill_states_map.nii`. - - If a `FillStatesCalculator` throws an exception (e.g. due to missing configuration), the pipeline logs the error and rethrows for `fillstates` so that the CLI exits with an error. - -### 3.4. CLI and Options for Fill‑states - -- `main.cpp` defines a dedicated `fillstates` subcommand: - -```startLine:endLine:localizer/src/main.cpp -// Fill-states subcommand -argparse::ArgumentParser fillstates_cmd("fillstates"); -fillstates_cmd.add_description("Calculate fill-states metric for PET images"); -addFillStatesArguments(fillstates_cmd); -... -program.add_subparser(fillstates_cmd); -... -} else if (program.is_subcommand_used("fillstates")) { - return executeFillStatesCommand(fillstates_cmd, fullCommand); -} -``` +### 6. Add config and assets -- `cli/Options` defines a `FillStatesCommandOptions` struct and `addFillStatesArguments`, which extends the common SUVr‑derived options with a required `--tracer` argument (`fbp`, `fdg`, `ftp`). -- `cli/Commands::executeFillStatesCommand`: - - Parses `FillStatesCommandOptions` (including `--tracer`). - - Populates `ProcessingOptions` with: - - `selectedMetric = "fillstates"` - - `selectedMetricTracer = options.tracer` - - Runs `ProcessingPipeline::process(...)`. - - Prints the resulting `FillStates` values and (optionally) the underlying SUVr if `--suvr` is set. - -### 3.5. Configuration for Fill‑states - -The `fillstates` metric is configured via new sections in `assets/configs/config.toml`: - -```toml -[fillstates.tracers.fbp] -mean = "assets/nii/fill_states/fs_FBP_mean.nii.gz" -std = "assets/nii/fill_states/fs_FBP_std.nii.gz" -roi = "assets/nii/fill_states/fs_FBP_meta_roi.nii" - -[fillstates.tracers.fdg] -mean = "assets/nii/fill_states/fs_FDG_mean.nii.gz" -std = "assets/nii/fill_states/fs_FDG_std.nii.gz" -roi = "assets/nii/fill_states/fs_FDG_meta_roi.nii" - -[fillstates.tracers.ftp] -mean = "assets/nii/fill_states/fs_FTP_mean.nii.gz" -std = "assets/nii/fill_states/fs_FTP_std.nii.gz" -roi = "assets/nii/CenTauR.nii" -``` +Typical things you may need: + +- mask paths +- template paths +- tracer parameters +- model paths +- feature flags such as `enabled = true` + +If a metric should only work with one config profile, encode that intentionally and validate it in the service. + +### 7. Add tests + +At minimum: + +- add `--help` and basic invocation coverage to [test_quick_cli.py](../localizer/src/tests/test_quick_cli.py) +- add a metric-specific CLI test under `localizer/src/tests/` + +Examples: + +- [test_abetaload_cli.py](../localizer/src/tests/test_abetaload_cli.py) +- [test_abetaindex_cli.py](../localizer/src/tests/test_abetaindex_cli.py) +- [test_fillstates_cli.py](../localizer/src/tests/test_fillstates_cli.py) +- [test_adad_cli.py](../localizer/src/tests/test_adad_cli.py) + +If the metric has known reference values, add a dedicated accuracy test. + +### 8. Update documentation + +Update: + +- [localizer/src/README.md](../localizer/src/README.md) for CLI behavior +- root [README.md](../README.md) if the metric is user-facing at the product level +- metric reproduction docs under `docs/` if applicable + +## Design Rules + +When extending the project, prefer these rules: -These keys are resolved relative to the executable directory (via `Common::getExecutablePath()`), so they should point to valid NIfTI files installed under `assets/nii/`. +- keep `core` ignorant of metric business logic +- keep metric workflow inside the metric service +- keep computation inside the calculator +- put cross-metric workflow helpers in `metrics/shared/`, not `core` +- avoid adding vague abstractions like `Logic`, `Handler`, or a global metric pipeline unless there is a strong, proven need +- validate configuration compatibility explicitly -If any of `mean`, `std`, or `roi` is missing for the requested tracer, `FillStatesCalculator::getTracerResources()` throws a `std::runtime_error`, which ultimately causes the CLI command to exit with a non‑zero return code and an error message. +## Notes on Existing Metrics -### 3.6. Testing and Examples +- `centiloid`, `centaur`, `centaurz` + - normalization + metric conversion workflows +- `suvr` + - custom VOI/reference masks supplied through CLI +- `fillstates` + - writes an extra mask output +- `abetaload` + - template decomposition metric +- `abetaindex` + - AV45-only template metric with config gating +- `adad` + - decoupler-based metric, single-file only -The repository includes several tests you can use as references when adding new metrics: +## What Not to Reintroduce -- **Centiloid / CenTauR / CenTauRz CLI tests:** - - `localizer/src/tests/test_acc_centiloid_centaurz_cli.py` -- **Fill‑states CLI & accuracy tests:** - - `localizer/src/tests/test_fillstates_cli.py` – basic `fillstates` command behavior and CLI wiring. - - `localizer/src/tests/test_acc_fill_states_cli.py` – end‑to‑end evaluation of FillStates vs. ground truth (`tests/test_acc_fill_states/gt.csv`). -- **Other utilities:** - - `tests/calibrate_metrics.py` and `tests/calibrate_tracer/*` – scripts and data for tracer calibration. - - `tests/test_nii_gz_cli.py` – example of NIfTI I/O and CLI usage. +Do not add back: -When implementing a new metric, you can: +- `IMetricLogic` +- `IMetricService` as a core dispatch abstraction +- `IMetricModuleRegistry` in `core` +- `PipelineApplication` +- `NullMetric` +- a core-owned global metric orchestration layer -1. **Clone** the `FillStates` pattern for tracer‑dependent metrics (if applicable). -2. **Add a dedicated `test_*.py`** for CLI behavior (single‑case sanity check). -3. **Add an accuracy test** under `tests/` with small NIfTI volumes and known ground truth, similar to `test_acc_fill_states_cli.py`. +Those abstractions were removed on purpose because they blurred the boundary between shared infrastructure and metric business logic. -This approach ensures your new metric is: +## Practical References -- Properly integrated into the factory and pipeline. -- Accessible via a clear CLI interface. -- Covered by automated tests for regression protection. +Useful files when working on new modules: +- [localizer/src/core/README.md](../localizer/src/core/README.md) +- [localizer/src/core/di/Bootstrap.cpp](../localizer/src/core/di/Bootstrap.cpp) +- [localizer/src/core/common/NormalizationContracts.h](../localizer/src/core/common/NormalizationContracts.h) +- [localizer/src/metrics/shared/SingleRunner.h](../localizer/src/metrics/shared/SingleRunner.h) +- [localizer/src/metrics/shared/BatchRunner.h](../localizer/src/metrics/shared/BatchRunner.h) +- [localizer/src/metrics/shared/BatchLogging.cpp](../localizer/src/metrics/shared/BatchLogging.cpp) +- [localizer/src/metrics/ModuleCatalog.cpp](../localizer/src/metrics/ModuleCatalog.cpp) +If a new feature does not clearly fit the current boundaries, fix the boundary first in your head before writing code. Most architecture drift in this codebase comes from letting `core` absorb metric-specific workflow. diff --git a/docs/rigid-iterative-ct-benchmark.md b/docs/rigid-iterative-ct-benchmark.md new file mode 100644 index 0000000..3c91499 --- /dev/null +++ b/docs/rigid-iterative-ct-benchmark.md @@ -0,0 +1,126 @@ +# Rigid Iterative CT Benchmark + +Date: 2026-06-16 + +Input: + +- `/workspace/localizer/Testing/CT.nii.gz` + +Build: + +```bash +docker compose -f docker-compose.core.yml run --rm dccc-core /workspace/scripts/docker-build-core.sh +``` + +Benchmark command shape: + +```bash +docker compose -f docker-compose.core.yml run --rm dccc-core \ + python3 -c '... subprocess.run(["./install/bin/DCCCcore", "rigid", "--input", "/workspace/localizer/Testing/CT.nii.gz", "--output", "/tmp/dccc_ct_rigid_*.nii", "--iterative"]) ...' +``` + +`/usr/bin/time` is not installed in the development container, so the benchmark used Python `time.perf_counter()` for wall time and `resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss` for peak child-process RSS. + +## Baseline + +Baseline command: + +```bash +./install/bin/DCCCcore rigid \ + --input /workspace/localizer/Testing/CT.nii.gz \ + --output /tmp/dccc_ct_rigid_baseline.nii \ + --iterative +``` + +Result: + +- Return code: 0 +- Wall time: 32.233 seconds +- Max RSS: 6,810,932 KB + +The slow path was not caused by repeatedly reading the input file from disk. The input is loaded once. The main issues were: + +- `rigidOnly` requests still computed VoxelMorph spatial normalization, then discarded it. +- Rigid preprocessing computed intensity percentiles by sorting all high-resolution voxels as `double`. +- The iterative loop wrote `rigid_iter.nii` every pass, but that file was never read. + +## Changes Tested + +Implemented changes: + +- Added rigid-only normalization entry points so the `rigid` command stops before VoxelMorph. +- Split rigid alignment and VoxelMorph warping into separate lazy-loaded components, so `rigid` does not load VoxelMorph and `--manual-fov` does not load the rigid model. +- Removed the old combined `RegistrationPipeline` from the build to avoid reintroducing a runtime path that initializes both engines together. +- Removed the unused per-iteration `rigid_iter.nii` temporary write. +- Replaced full percentile sorting with `std::nth_element` over a `float` voxel buffer. + +An attempted low-resolution iterative working-image cache was rejected. It improved speed, but changed the final affine too much on `CT.nii.gz`; the old/new sform difference reached 19.5 mm in translation. The final implementation therefore preserves the original high-resolution iterative affine-estimation semantics. + +## Results + +Rejected low-resolution-cache experiment: + +- Return code: 0 +- Wall time: 16.848 seconds +- Max old/new sform absolute difference: 19.525314 mm + +Final result before component split: + +- Return code: 0 +- Wall time: 16.991 seconds +- Max old/new sform absolute difference: 0.0 +- Max old/new qoffset absolute difference: 0.0 +- Max old/new quaternion absolute difference: 0.0 +- Max RSS: 6,241,604 KB + +Final result after Rigid/VoxelMorph component split: + +- Return code: 0 +- Wall time: 6.270 seconds +- Max old/new sform absolute difference: 0.0 +- Max old/new qoffset absolute difference: 0.0 +- Max old/new quaternion absolute difference: 0.0 +- Max RSS: 2,009,480 KB + +An earlier final timing run before the affine correction measured 16.848 seconds and max RSS 6,603,216 KB, but that variant is not valid because the affine changed. + +After rigid-only branching, removal of the low-resolution-cache experiment, and component-level lazy loading, the accepted output preserves the old affine exactly for this CT benchmark while avoiding discarded VoxelMorph work and VoxelMorph model loading. + +Earlier intermediate result after rigid-only branching and low-resolution iterative working image, before the percentile change: + +- Return code: 0 +- Wall time: 23.528 seconds +- Max RSS: 6,543,312 KB + +This intermediate low-resolution result is included only for auditability and is not the accepted implementation. + +Compared with baseline: + +- Wall time improved by about 80.5%. +- Peak RSS improved because the `rigid` path no longer loads the VoxelMorph model or padded VoxelMorph processing buffers. + +## Verification + +Commands run: + +```bash +docker compose -f docker-compose.core.yml run --rm dccc-core /workspace/scripts/docker-build-core.sh +docker compose -f docker-compose.core.yml run --rm dccc-core pytest tests/test_normalize_cli.py -q +docker compose -f docker-compose.core.yml run --rm dccc-core pytest tests/test_acc_centiloid_centaurz_cli.py -q +``` + +Test result: + +- `tests/test_normalize_cli.py`: 4 passed in 32.11 seconds +- `tests/test_acc_centiloid_centaurz_cli.py`: 30 passed in 445.83 seconds +- `DCCCcore --version`: `4.2.3-alpha` + +Component split behavior check: + +- With a config whose `models.rigid` path points to a missing file, `normalize --manual-fov` returned 0. +- With the same config, `rigid` returned 1 while loading the missing rigid model. +- This confirms `--manual-fov` no longer initializes the rigid engine, while rigid paths still validate the rigid model. + +## Notes + +Further speed or memory reductions would require changing the iterative model input or the first rigid preprocessing pass so that very high-resolution images are downsampled before percentile clipping and smoothing. The low-resolution-cache experiment shows that this is a behavioral change and must be validated against affine accuracy before acceptance. diff --git a/localizer/src/CMakeLists.txt b/localizer/src/CMakeLists.txt index 7d36634..24f1d31 100644 --- a/localizer/src/CMakeLists.txt +++ b/localizer/src/CMakeLists.txt @@ -1,7 +1,9 @@ -# CMakeList.txt: CentiloidCalculator Refactored Version +# CMakeList.txt: DCCCcore Refactored Version cmake_minimum_required(VERSION 3.21) -project(CentiloidCalculator VERSION 3.4.0) +project(DCCCcore VERSION 4.2.3) +set(DCCCCORE_VERSION_SUFFIX "-alpha") +set(DCCCCORE_SOFTWARE_VERSION "${PROJECT_VERSION}${DCCCCORE_VERSION_SUFFIX}") # Set C++ standard set(CMAKE_CXX_STANDARD 17) @@ -24,110 +26,185 @@ find_package(rapidcsv CONFIG REQUIRED) # Configure version header configure_file( - ${CMAKE_CURRENT_SOURCE_DIR}/config/Version.h.in - ${CMAKE_CURRENT_SOURCE_DIR}/config/Version.h + ${CMAKE_CURRENT_SOURCE_DIR}/core/config/Version.h.in + ${CMAKE_CURRENT_SOURCE_DIR}/core/config/Version.h @ONLY ) # Create executable -add_executable(CentiloidCalculator main.cpp) +add_executable(DCCCcore main.cpp) # Add interface headers -target_sources(CentiloidCalculator PRIVATE - interfaces/ISpatialNormalizer.h - interfaces/IMetricCalculator.h - interfaces/IConfiguration.h +target_sources(DCCCcore PRIVATE + core/interfaces/ISpatialNormalizer.h + core/interfaces/IConfiguration.h ) # Add configuration management -target_sources(CentiloidCalculator PRIVATE - config/Configuration.h - config/Configuration.cpp - config/Version.h -) - -# Add metric calculators -target_sources(CentiloidCalculator PRIVATE - calculators/SUVrCalculator.h - calculators/SUVrCalculator.cpp - calculators/CentiloidCalculator.h - calculators/CentiloidCalculator.cpp - calculators/CenTauRCalculator.h - calculators/CenTauRCalculator.cpp - calculators/CenTauRzCalculator.h - calculators/CenTauRzCalculator.cpp - calculators/FillStatesCalculator.h - calculators/FillStatesCalculator.cpp +target_sources(DCCCcore PRIVATE + core/config/Configuration.h + core/config/Configuration.cpp + core/config/ConfigLoader.h + core/config/ConfigLoader.cpp + core/config/Version.h ) # Add spatial normalizers -target_sources(CentiloidCalculator PRIVATE - normalizers/RigidVoxelMorphNormalizer.h - normalizers/RigidVoxelMorphNormalizer.cpp - normalizers/RegistrationPipeline.h - normalizers/RegistrationPipeline.cpp - normalizers/RigidRegistrationEngine.h - normalizers/RigidRegistrationEngine.cpp - normalizers/NonlinearRegistrationEngine.h - normalizers/NonlinearRegistrationEngine.cpp +target_sources(DCCCcore PRIVATE + core/normalizers/RigidVoxelMorphNormalizer.h + core/normalizers/RigidVoxelMorphNormalizer.cpp + core/normalizers/RigidAlignmentNormalizer.h + core/normalizers/RigidAlignmentNormalizer.cpp + core/normalizers/VoxelMorphNormalizer.h + core/normalizers/VoxelMorphNormalizer.cpp + core/normalizers/RigidRegistrationEngine.h + core/normalizers/RigidRegistrationEngine.cpp + core/normalizers/NonlinearRegistrationEngine.h + core/normalizers/NonlinearRegistrationEngine.cpp ) # Add image preprocessing module -target_sources(CentiloidCalculator PRIVATE - preprocessing/ImagePreprocessor.h - preprocessing/ImagePreprocessor.cpp -) - -# Add factories -target_sources(CentiloidCalculator PRIVATE - factories/SpatialNormalizerFactory.h - factories/SpatialNormalizerFactory.cpp - factories/MetricCalculatorFactory.h - factories/MetricCalculatorFactory.cpp -) - -# Add processing pipeline -target_sources(CentiloidCalculator PRIVATE - pipeline/ProcessingPipeline.h - pipeline/ProcessingPipeline.cpp - pipeline/BatchProcessor.h - pipeline/BatchProcessor.cpp +target_sources(DCCCcore PRIVATE + core/preprocessing/ImagePreprocessor.h + core/preprocessing/ImagePreprocessor.cpp ) # Add utility sources -target_sources(CentiloidCalculator PRIVATE - utils/common.h - utils/common.cpp - cli/Options.h - cli/Options.cpp - cli/Commands.h - cli/Commands.cpp -) - -# Add decoupler sources -target_sources(CentiloidCalculator PRIVATE - decouplers/Decoupler.h - decouplers/Decoupler.cpp +target_sources(DCCCcore PRIVATE + core/common/Common.h + core/common/ImageTypes.h + core/common/NiftiTypes.h + core/common/NiftiIO.h + core/common/NiftiIO.cpp + core/common/ImageOps.h + core/common/ImageOps.cpp + core/common/PathUtils.h + core/common/PathUtils.cpp + core/common/Filesystem.h + core/common/Filesystem.cpp + core/common/Logging.h + core/common/Logging.cpp + core/common/NormalizationContracts.h + core/common/OnnxPath.h + core/interfaces/ISpatialNormalizationCLI.h + core/di/ServiceContainer.h + core/services/ISpatialNormalizationService.h + core/services/SpatialNormalizationService.h + core/services/SpatialNormalizationService.cpp + core/services/IFileService.h + core/services/FileService.h + core/services/FileService.cpp + core/di/Bootstrap.h + core/di/Bootstrap.cpp + metrics/shared/MetricTypes.h + metrics/shared/MetricRunResult.h + metrics/shared/IMetricCLI.h + metrics/shared/MetricRegistry.h + metrics/shared/MetricRegistry.cpp + metrics/shared/BatchLogging.h + metrics/shared/BatchLogging.cpp + metrics/shared/SingleRunner.h + metrics/shared/BatchRunner.h + metrics/shared/DebugPathHelpers.h + metrics/suvr/SUVrCLI.h + metrics/suvr/SUVrCLI.cpp + metrics/suvr/SUVrService.h + metrics/suvr/SUVrService.cpp + metrics/suvr/SUVrCalculator.h + metrics/suvr/SUVrCalculator.cpp + metrics/suvr/SUVrModule.h + metrics/suvr/SUVrModule.cpp + metrics/centaur/CenTauRCLI.h + metrics/centaur/CenTauRCLI.cpp + metrics/centaur/CenTauRService.h + metrics/centaur/CenTauRService.cpp + metrics/centaur/CenTauRCalculator.h + metrics/centaur/CenTauRCalculator.cpp + metrics/centaur/CenTauRModule.h + metrics/centaur/CenTauRModule.cpp + metrics/centaurz/CentaurzCLI.h + metrics/centaurz/CentaurzCLI.cpp + metrics/centaurz/CentaurzService.h + metrics/centaurz/CentaurzService.cpp + metrics/centaurz/CenTauRzCalculator.h + metrics/centaurz/CenTauRzCalculator.cpp + metrics/centaurz/CentaurzModule.h + metrics/centaurz/CentaurzModule.cpp + metrics/centiloid/CentiloidCLI.h + metrics/centiloid/CentiloidCLI.cpp + metrics/centiloid/CentiloidService.h + metrics/centiloid/CentiloidService.cpp + metrics/centiloid/CentiloidCalculator.h + metrics/centiloid/CentiloidCalculator.cpp + metrics/centiloid/CentiloidModule.h + metrics/centiloid/CentiloidModule.cpp + metrics/fillstates/FillStatesCLI.h + metrics/fillstates/FillStatesCLI.cpp + metrics/fillstates/FillStatesService.h + metrics/fillstates/FillStatesService.cpp + metrics/fillstates/FillStatesCalculator.h + metrics/fillstates/FillStatesCalculator.cpp + metrics/fillstates/FillStatesModule.h + metrics/fillstates/FillStatesModule.cpp + metrics/abetaindex/AbetaIndexCLI.h + metrics/abetaindex/AbetaIndexCLI.cpp + metrics/abetaindex/AbetaIndexService.h + metrics/abetaindex/AbetaIndexService.cpp + metrics/abetaindex/AbetaIndexCalculator.h + metrics/abetaindex/AbetaIndexCalculator.cpp + metrics/abetaindex/AbetaIndexModule.h + metrics/abetaindex/AbetaIndexModule.cpp + metrics/abetaload/AbetaLoadCLI.h + metrics/abetaload/AbetaLoadCLI.cpp + metrics/abetaload/AbetaLoadService.h + metrics/abetaload/AbetaLoadService.cpp + metrics/abetaload/AbetaLoadCalculator.h + metrics/abetaload/AbetaLoadCalculator.cpp + metrics/abetaload/AbetaLoadModule.h + metrics/abetaload/AbetaLoadModule.cpp + metrics/adad/ADADCLI.h + metrics/adad/ADADCLI.cpp + metrics/adad/ADADService.h + metrics/adad/ADADService.cpp + metrics/adad/ADADModule.h + metrics/adad/ADADModule.cpp + metrics/adad/Decoupler.h + metrics/adad/Decoupler.cpp + metrics/list/MetricsCLI.h + metrics/list/MetricsCLI.cpp + metrics/ModuleCatalog.h + metrics/ModuleCatalog.cpp + spatialNormalizations/ModuleCatalog.h + spatialNormalizations/ModuleCatalog.cpp + spatialNormalizations/CLIOptions.h + spatialNormalizations/CLIOptions.cpp + spatialNormalizations/standard/NormalizeCLI.h + spatialNormalizations/standard/NormalizeCLI.cpp + spatialNormalizations/adni/AdniPetCoreCLI.h + spatialNormalizations/adni/AdniPetCoreCLI.cpp + spatialNormalizations/rigid/RigidCLI.h + spatialNormalizations/rigid/RigidCLI.cpp ) # Link libraries -target_link_libraries(CentiloidCalculator PRIVATE +target_link_libraries(DCCCcore PRIVATE itk::itk onnxruntime::onnxruntime argparse::argparse tomlplusplus::tomlplusplus Eigen3::Eigen + rapidcsv::rapidcsv ) # Compile options if(MSVC) - target_compile_options(CentiloidCalculator PRIVATE /W4) + target_compile_options(DCCCcore PRIVATE /W4) else() - target_compile_options(CentiloidCalculator PRIVATE -Wall -Wextra -Wpedantic) + target_compile_options(DCCCcore PRIVATE -Wall -Wextra -Wpedantic) endif() # Set install prefix -set(CMAKE_INSTALL_PREFIX "E:/projects/paper/CentiloidCalculatorInstall" CACHE PATH "Installation directory") +set(CMAKE_INSTALL_PREFIX "E:/projects/paper/DCCCcoreInstall" CACHE PATH "Installation directory") include(InstallRequiredSystemLibraries) include(GNUInstallDirs) @@ -139,7 +216,7 @@ set(centiloid_install_target_args ) if(APPLE) - set_target_properties(CentiloidCalculator PROPERTIES + set_target_properties(DCCCcore PROPERTIES BUILD_RPATH "@loader_path" INSTALL_RPATH "@loader_path" ) @@ -150,7 +227,7 @@ if(APPLE) ) elseif(UNIX) # Use $ORIGIN so the executable can locate co-located shared libraries. - set_target_properties(CentiloidCalculator PROPERTIES + set_target_properties(DCCCcore PROPERTIES BUILD_RPATH "$ORIGIN" INSTALL_RPATH "$ORIGIN" ) @@ -165,7 +242,7 @@ if(centiloid_runtime_dependency_args) list(PREPEND centiloid_install_target_args ${centiloid_runtime_dependency_args}) endif() -install(TARGETS CentiloidCalculator +install(TARGETS DCCCcore ${centiloid_install_target_args} ) @@ -176,7 +253,7 @@ install(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/assets/" # Install dependent runtime DLLs next to the executable (requires CMake 3.21+) if(WIN32) - install(FILES $ + install(FILES $ DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT Runtime) endif() @@ -197,4 +274,4 @@ endif() # Output information message(STATUS "ITK_LIBRARIES: ${ITK_LIBRARIES}") message(STATUS "CMAKE_INSTALL_PREFIX: ${CMAKE_INSTALL_PREFIX}") -message(STATUS "Project configured for refactored CentiloidCalculator v${PROJECT_VERSION}") \ No newline at end of file +message(STATUS "Project configured for refactored DCCCcore v${DCCCCORE_SOFTWARE_VERSION}") diff --git a/localizer/src/README.md b/localizer/src/README.md index 0c37242..27af390 100644 --- a/localizer/src/README.md +++ b/localizer/src/README.md @@ -8,11 +8,11 @@ The Deep Cascaded Cerebral Calculator Core is a comprehensive C++ toolkit design ### Key Features -- **Multi-biomarker Support**: Calculates Centiloid (amyloid), CenTauR/CenTauRz (tau), fill-states, and custom SUVr metrics +- **Multi-biomarker Support**: Calculates Centiloid (amyloid), AbetaIndex/AbetaLoad (amyloid), CenTauR/CenTauRz (tau), fill-states, and custom SUVr metrics - **Deep Learning Pipeline**: Utilizes ONNX-based neural networks for spatial normalization - **Modular Architecture**: Extensible design with clean interfaces for adding new biomarkers - **Multi-tracer Compatibility**: Supports various PET tracers with tracer-specific calibrations -- **Decoupling Analysis**: Advanced pathology-specific signal extraction +- **ADAD Scoring**: Built-in decoupling pipeline for abeta/tau modalities via `adad` - **Configuration-driven**: Flexible TOML-based configuration system ## Usage @@ -22,27 +22,27 @@ The software provides a command-line interface with multiple subcommands for dif ### Basic Commands #### Centiloid Analysis -Calculate standardized amyloid burden scores: +Calculate standardized amyloid burden scores using the refactored CLI: ```bash # Basic Centiloid calculation -./CentiloidCalculator centiloid --input amyloid_pet.nii --output result.nii +./DCCCcore centiloid --input amyloid_pet.nii --output result.nii # With configuration file -./CentiloidCalculator centiloid --input amyloid_pet.nii --output result.nii --config custom_config.toml +./DCCCcore centiloid --input amyloid_pet.nii --output result.nii --config custom_config.toml # Include SUVr values in output -./CentiloidCalculator centiloid --input amyloid_pet.nii --output result.nii --suvr +./DCCCcore centiloid --input amyloid_pet.nii --output result.nii --suvr # Skip spatial normalization (pre-normalized images) -./CentiloidCalculator centiloid --input normalized_pet.nii --output result.nii --skip-normalization +./DCCCcore centiloid --input normalized_pet.nii --output result.nii --skip-normalization ``` Batch processing of multiple amyloid PET images: ```bash # Process all .nii / .nii.gz files under input_dir and write outputs to output_dir -./CentiloidCalculator centiloid --input input_dir --output output_dir --batch +./DCCCcore centiloid --input input_dir --output output_dir --batch ``` #### CenTauR Analysis @@ -50,10 +50,20 @@ Calculate standardized tau burden scores: ```bash # CenTauR percentile scale -./CentiloidCalculator centaur --input tau_pet.nii --output result.nii +./DCCCcore centaur --input tau_pet.nii --output result.nii # CenTauRz z-score scale -./CentiloidCalculator centaurz --input tau_pet.nii --output result.nii +./DCCCcore centaurz --input tau_pet.nii --output result.nii +``` + +#### AbetaIndex Analysis +Calculate the AV45-only AbetaIndex coefficient using the `mean`, `PC1`, and fixed-`PC2` templates: + +```bash +./DCCCcore abetaindex --input amyloid_pet.nii --output result.nii + +# Batch mode +./DCCCcore abetaindex --input input_dir --output output_dir --batch ``` #### Fill-states Analysis @@ -61,13 +71,13 @@ Calculate fill-states metric based on voxel-wise z-scores within tracer-specific ```bash # Amyloid tracer (FBP) fill-states -./CentiloidCalculator fillstates --input amyloid_pet.nii --output result.nii --tracer fbp +./DCCCcore fillstates --input amyloid_pet.nii --output result.nii --tracer fbp # FDG neurodegeneration fill-states -./CentiloidCalculator fillstates --input fdg_pet.nii --output result.nii --tracer fdg +./DCCCcore fillstates --input fdg_pet.nii --output result.nii --tracer fdg # FTP tau fill-states -./CentiloidCalculator fillstates --input ftp_pet.nii --output result.nii --tracer ftp +./DCCCcore fillstates --input ftp_pet.nii --output result.nii --tracer ftp ``` The command produces: @@ -79,14 +89,14 @@ The command produces: Calculate SUVr with user-defined regions: ```bash -./CentiloidCalculator suvr --input pet.nii --output result.nii \ +./DCCCcore suvr --input pet.nii --output result.nii \ --voi-mask target_region.nii --ref-mask reference_region.nii ``` Batch SUVr calculation with user-defined regions: ```bash -./CentiloidCalculator suvr --input input_dir --output output_dir --batch \ +./DCCCcore suvr --input input_dir --output output_dir --batch \ --voi-mask target_region.nii --ref-mask reference_region.nii ``` @@ -95,24 +105,26 @@ Perform spatial standardization without metric calculation: ```bash # Standard normalization -./CentiloidCalculator normalize --input pet.nii --output normalized.nii +./DCCCcore normalize --input pet.nii --output normalized.nii # ADNI-style processing -./CentiloidCalculator normalize --input pet.nii --output normalized.nii --ADNI-PET-core +./DCCCcore adni-pet-core --input pet.nii --output normalized.nii # Iterative rigid registration -./CentiloidCalculator normalize --input pet.nii --output normalized.nii --iterative +./DCCCcore normalize --input pet.nii --output normalized.nii --iterative +./DCCCcore adni-pet-core --input pet.nii --output normalized.nii --iterative +./DCCCcore rigid --input pet.nii --output rigid.nii --iterative ``` -#### Decoupling Analysis -Extract pathology-specific signals: +#### ADAD Analysis +Run the ADAD decoupling-based metric: ```bash -# Amyloid decoupling -./CentiloidCalculator decouple --input pet.nii --output decoupled.nii --modality abeta +# Default (abeta) modality +./DCCCcore adad --input pet.nii --output result.nii -# Tau decoupling -./CentiloidCalculator decouple --input pet.nii --output decoupled.nii --modality tau +# Tau modality with iterative rigid alignment +./DCCCcore adad --input pet.nii --output result.nii --modality tau --iterative ``` ### Command Options @@ -121,13 +133,61 @@ Extract pathology-specific signals: |--------|-------------| | `--config ` | Configuration file path (default: config.toml) | | `--debug` | Enable debug mode with intermediate outputs | -| `--batch` | Enable batch processing mode. In batch mode, `--input` and `--output` are treated as directories, all `.nii` / `.nii.gz` files in the input directory are processed, and outputs are written as `_processed.nii` together with `results.csv` and `batch_info.txt` in the output directory. Currently supported for `centiloid`, `centaur`, `centaurz`, and `suvr` commands. When registration is enabled (no `--skip-normalization`), the output directory must be empty to avoid overwriting. | +| `--batch` | Enable batch processing mode. In batch mode, `--input` and `--output` are treated as directories, all `.nii` / `.nii.gz` files in the input directory are processed, and outputs use the input basename plus each command's preset suffix. Metric commands also generate `results.csv`, and batch-capable commands generate `batch_info.txt` in the output directory. Currently supported for `centiloid`, `centaur`, `centaurz`, `suvr`, `abetaindex`, `abetaload`, `rigid`, and `adni-pet-core`. When registration is enabled (no `--skip-normalization`), the output directory must be empty to avoid overwriting. | +| `--bids ` | Treat `--input` as a PET-BIDS dataset root, recursively process PET NIfTI files under `pet/` whose BIDS relative path, filename, or basename matches ``, and write outputs to the `--output` directory using the original BIDS basename plus the command suffix, such as `sub-01_ses-01_pet_rigid_aligned.nii` or `sub-01_ses-01_pet_ADNI_style.nii`. | | `--iterative` | Use iterative rigid transformation | | `--manual-fov` | Enable manual field-of-view placement | | `--skip-normalization` | Skip spatial normalization step | | `--suvr` | Include SUVr values in metric outputs | -| `--tracer ` | Tracer type for fill-states metric (`fillstates` command only). Supported values: `fbp`, `fdg`, `ftp`. Required for `fillstates`. | +| `--tracer ` | Tracer type for fill-states metric (`fillstates` command only). Supported values: `fbp`, `fdg`, `ftp`. | +| `--modality ` | Decoupling modality for `adad` (`abeta` or `tau`). | ## Developers -To add your own brain PET metric, please refer to the [developer guide](../../docs/developer_guide.md). \ No newline at end of file +To add your own brain PET metric, please refer to the [developer guide](../../docs/developer_guide.md). + +## Docker Development Environment + +The C++ core can be built in a Linux Docker container with Conan and CMake. This is the recommended path for reproducible development across machines. + +Build the development image: + +```bash +docker compose -f docker-compose.core.yml build +``` + +Build and install the C++ core: + +```bash +docker compose -f docker-compose.core.yml run --rm dccc-core /workspace/scripts/docker-build-core.sh +``` + +For Linux release builds that must run on older distributions without glibc 2.34, use the legacy compatibility container instead. It is based on the manylinux2014 / CentOS 7 toolchain so generated Linux binaries target glibc 2.17, which covers RHEL/CentOS 7-era images and avoids accidental `GLIBC_2.34` symbol requirements: + +```bash +docker compose -f docker-compose.core.yml build dccc-core-legacy +docker compose -f docker-compose.core.yml run --rm dccc-core-legacy /workspace/scripts/docker-build-core.sh +``` + +The installed executable will be written to `localizer/src/install/bin/DCCCcore`. Conan packages and the CMake build tree are kept in Docker volumes so later builds can reuse downloaded and compiled dependencies. The default development container and the legacy compatibility container use separate Conan and CMake volumes to avoid mixing dependency builds from different glibc baselines. The script defaults to `CONAN_CPPSTD=gnu17` on Linux to reuse more Conan Center binaries; use `CONAN_CPPSTD=17` if you need a strict non-GNU C++17 profile. The legacy container also sets `CONAN_BUILD_ARGS="--build=missing --build=b2/* --build=m4/* --build=autoconf/* --build=automake/* --build=libtool/* --build=pkgconf/*"` so native build tools such as Boost's `b2` and autotools packages used by libcurl are compiled inside the manylinux2014 environment instead of downloading ConanCenter binaries that may require newer glibc symbols such as `GLIBC_2.34`. + +The first run can still take a long time because Conan Center may not provide matching Linux binaries for heavy packages such as ITK, ONNX Runtime, Boost, HDF5, GDCM, or TBB. Let the first build finish once on a machine, then keep the Docker volumes for normal incremental development. + +Run the CLI from an interactive shell: + +```bash +docker compose -f docker-compose.core.yml run --rm dccc-core +./install/bin/DCCCcore --help +``` + +Run Python CLI tests after building: + +```bash +docker compose -f docker-compose.core.yml run --rm dccc-core pytest tests +``` + +To force a clean dependency/build cache, remove the Docker volumes: + +```bash +docker compose -f docker-compose.core.yml down -v +``` diff --git a/localizer/src/assets/configs/config.fast_and_acc.toml b/localizer/src/assets/configs/config.fast_and_acc.toml index 37adee7..97eb734 100644 --- a/localizer/src/assets/configs/config.fast_and_acc.toml +++ b/localizer/src/assets/configs/config.fast_and_acc.toml @@ -1,4 +1,4 @@ -# CentiloidCalculator configuration file (TOML format) - Fast and accurate version +# DCCCcore configuration file (TOML format) - Fast and accurate version # This file contains all configurable parameters # Model file paths (relative to executable directory) @@ -128,3 +128,6 @@ ref_mask = "whole_cerebral" [suvr.regions.centaur] voi_mask = "centaur_voi" ref_mask = "centaur_ref" + +# AbetaIndex is intentionally not configured in fast_and_acc. +# It depends on the standard spatial-normalization strategy used by config.toml. diff --git a/localizer/src/assets/configs/config.toml b/localizer/src/assets/configs/config.toml index 480e2fc..a563cae 100644 --- a/localizer/src/assets/configs/config.toml +++ b/localizer/src/assets/configs/config.toml @@ -1,4 +1,4 @@ -# CentiloidCalculator configuration file (TOML format) +# DCCCcore configuration file (TOML format) # This file contains all configurable parameters # Model file paths (relative to executable directory) @@ -24,6 +24,11 @@ tau_decoupler = [ [templates] adni_pet_core = "assets/nii/ADNI_empty.nii" padded = "assets/nii/paddedTemplate.nii" +abeta_ns = "assets/nii/abeta_load/NS_AV45.nii.gz" +abeta_k = "assets/nii/abeta_load/K_AV45.nii.gz" +abeta_index_mean = "assets/nii/abeta_index/mean.nii.gz" +abeta_index_pc1 = "assets/nii/abeta_index/PC1.nii.gz" +abeta_index_pc2 = "assets/nii/abeta_index/PC2.nii.gz" # Mask file paths [masks] @@ -32,6 +37,10 @@ centiloid_voi = "assets/nii/voi_ctx_2mm.nii" whole_cerebral = "assets/nii/voi_WhlCbl_2mm.nii" centaur_voi = "assets/nii/CenTauR.nii" centaur_ref = "assets/nii/voi_CerebGry_tau_2mm.nii" +centaurz_mesial_temporal_voi = "assets/nii/centaurz/Mesial_CenTauR.nii.gz" +centaurz_meta_temporal_voi = "assets/nii/centaurz/Meta_CenTauR.nii.gz" +centaurz_temporo_parietal_voi = "assets/nii/centaurz/TP_CenTauR.nii.gz" +centaurz_frontal_voi = "assets/nii/centaurz/Frontal_CenTauR.nii.gz" # Processing parameters [processing] @@ -115,6 +124,102 @@ intercept = -15.57 slope = 16.73 intercept = -15.34 +[centaurz.detailed_regions.mesial_temporal.tracers.ftp] +slope = 10.42 +intercept = -12.11 + +[centaurz.detailed_regions.mesial_temporal.tracers.gtp1] +slope = 7.88 +intercept = -8.75 + +[centaurz.detailed_regions.mesial_temporal.tracers.mk6240] +slope = 7.28 +intercept = -7.01 + +[centaurz.detailed_regions.mesial_temporal.tracers.pi2620] +slope = 6.03 +intercept = -6.83 + +[centaurz.detailed_regions.mesial_temporal.tracers.ro948] +slope = 11.76 +intercept = -13.08 + +[centaurz.detailed_regions.mesial_temporal.tracers.pmpbb3] +slope = 7.97 +intercept = -7.83 + +[centaurz.detailed_regions.meta_temporal.tracers.ftp] +slope = 12.95 +intercept = -15.37 + +[centaurz.detailed_regions.meta_temporal.tracers.gtp1] +slope = 9.60 +intercept = -11.10 + +[centaurz.detailed_regions.meta_temporal.tracers.mk6240] +slope = 9.36 +intercept = -10.60 + +[centaurz.detailed_regions.meta_temporal.tracers.pi2620] +slope = 7.78 +intercept = -9.33 + +[centaurz.detailed_regions.meta_temporal.tracers.ro948] +slope = 13.16 +intercept = -16.19 + +[centaurz.detailed_regions.meta_temporal.tracers.pmpbb3] +slope = 11.78 +intercept = -11.21 + +[centaurz.detailed_regions.temporo_parietal.tracers.ftp] +slope = 13.75 +intercept = -15.92 + +[centaurz.detailed_regions.temporo_parietal.tracers.gtp1] +slope = 10.84 +intercept = -12.27 + +[centaurz.detailed_regions.temporo_parietal.tracers.mk6240] +slope = 9.98 +intercept = -10.15 + +[centaurz.detailed_regions.temporo_parietal.tracers.pi2620] +slope = 8.21 +intercept = -9.52 + +[centaurz.detailed_regions.temporo_parietal.tracers.ro948] +slope = 13.05 +intercept = -15.62 + +[centaurz.detailed_regions.temporo_parietal.tracers.pmpbb3] +slope = 16.16 +intercept = -14.68 + +[centaurz.detailed_regions.frontal.tracers.ftp] +slope = 11.61 +intercept = -13.01 + +[centaurz.detailed_regions.frontal.tracers.gtp1] +slope = 9.41 +intercept = -9.71 + +[centaurz.detailed_regions.frontal.tracers.mk6240] +slope = 10.05 +intercept = -8.91 + +[centaurz.detailed_regions.frontal.tracers.pi2620] +slope = 9.07 +intercept = -9.01 + +[centaurz.detailed_regions.frontal.tracers.ro948] +slope = 12.61 +intercept = -13.45 + +[centaurz.detailed_regions.frontal.tracers.pmpbb3] +slope = 15.70 +intercept = -13.18 + # ADAD conversion formula [adad_abeta.tracers.pib] slope = 1.0 @@ -136,6 +241,11 @@ intercept = 0.80 slope = 0.90 intercept = 1.56 +# AbetaIndex configuration +[abetaindex] +enabled = true +tracer = "AV45" + # SUVr parameter configuration [suvr.regions.centiloid] voi_mask = "centiloid_voi" @@ -159,4 +269,4 @@ roi = "assets/nii/fill_states/fs_FDG_meta_roi.nii" [fillstates.tracers.ftp] mean = "assets/nii/fill_states/fs_FTP_mean.nii.gz" std = "assets/nii/fill_states/fs_FTP_std.nii.gz" -roi = "assets/nii/CenTauR.nii" \ No newline at end of file +roi = "assets/nii/CenTauR.nii" diff --git a/localizer/src/assets/models/registration/rigid.onnx b/localizer/src/assets/models/registration/rigid.onnx index 316831e..8004069 100644 --- a/localizer/src/assets/models/registration/rigid.onnx +++ b/localizer/src/assets/models/registration/rigid.onnx @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:431269729280df35543e3dc4d77d74189b8a11b9f76c9e021fd1ec5178a3d6fa -size 29263343 +oid sha256:a7cb51c0ccf2c82a19f289d8858e31d5f2b7b3437db4b34b4d5eb8417112ff2d +size 29263871 diff --git a/localizer/src/assets/nii/DAT/DAT_Occipital_Ref.nii b/localizer/src/assets/nii/DAT/DAT_Occipital_Ref.nii new file mode 100644 index 0000000..8bb6dc3 --- /dev/null +++ b/localizer/src/assets/nii/DAT/DAT_Occipital_Ref.nii @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:461a8436fba5ff05c74a106e614d119b48eb4bfa51c6d57f7476ce1667b02b41 +size 1805610 diff --git a/localizer/src/assets/nii/DAT/notes.md b/localizer/src/assets/nii/DAT/notes.md new file mode 100644 index 0000000..3ef56ab --- /dev/null +++ b/localizer/src/assets/nii/DAT/notes.md @@ -0,0 +1,6 @@ +> Dzialas, V., Bischof, G. N., Möllenhoff, K., Drzezga, A., & van Eimeren, T. (2025). Annals of Neurology, 98(1), 120–135. doi:10.1002/ana.27223 + +Occipital cortex from AAL atlas: +- Occipital_Sup +- Occipital_Mid +- Occipital_Inf \ No newline at end of file diff --git a/localizer/src/assets/nii/abeta_index/PC1.nii.gz b/localizer/src/assets/nii/abeta_index/PC1.nii.gz new file mode 100644 index 0000000..b85bead --- /dev/null +++ b/localizer/src/assets/nii/abeta_index/PC1.nii.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:572c1eb9f9f9b17f981e25ece08fe8b6e527227e7d9bd4bae6d881dc7fc3ccef +size 884566 diff --git a/localizer/src/assets/nii/abeta_index/PC2.nii.gz b/localizer/src/assets/nii/abeta_index/PC2.nii.gz new file mode 100644 index 0000000..37f367a --- /dev/null +++ b/localizer/src/assets/nii/abeta_index/PC2.nii.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:963f0fd5aff2afa4f1ae21f7a4b6f4e99fea8da3e9a25c4c43c3040fc4d76b4a +size 907197 diff --git a/localizer/src/assets/nii/abeta_index/mean.nii.gz b/localizer/src/assets/nii/abeta_index/mean.nii.gz new file mode 100644 index 0000000..8c3831e --- /dev/null +++ b/localizer/src/assets/nii/abeta_index/mean.nii.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:53445b0571b0ede1714a7765872ac58d0cd3a9d20a570306d387226497dceb42 +size 849412 diff --git a/localizer/src/assets/nii/abeta_load/K_AV45.nii.gz b/localizer/src/assets/nii/abeta_load/K_AV45.nii.gz new file mode 100644 index 0000000..eb9fcf6 --- /dev/null +++ b/localizer/src/assets/nii/abeta_load/K_AV45.nii.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:01634163b057f7c651462e239fbced0dcd99632e148263ef5b713c9e75bee1f4 +size 883572 diff --git a/localizer/src/assets/nii/abeta_load/NS_AV45.nii.gz b/localizer/src/assets/nii/abeta_load/NS_AV45.nii.gz new file mode 100644 index 0000000..d798b11 --- /dev/null +++ b/localizer/src/assets/nii/abeta_load/NS_AV45.nii.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1f3ed9cb0336b28d1f272287e6a235803c116de0092a9c810809bf42cc3ecb85 +size 854680 diff --git a/localizer/src/assets/nii/centaurz/Frontal_CenTauR.nii.gz b/localizer/src/assets/nii/centaurz/Frontal_CenTauR.nii.gz new file mode 100644 index 0000000..cf9bf1d --- /dev/null +++ b/localizer/src/assets/nii/centaurz/Frontal_CenTauR.nii.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fa173e3a36c41899e725fafafd0d307e554aca69bf49b339d161ea919089aa63 +size 5630 diff --git a/localizer/src/assets/nii/centaurz/Mesial_CenTauR.nii.gz b/localizer/src/assets/nii/centaurz/Mesial_CenTauR.nii.gz new file mode 100644 index 0000000..c749bc4 --- /dev/null +++ b/localizer/src/assets/nii/centaurz/Mesial_CenTauR.nii.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9bba1d3490a9525b7c6ba6e87b2e5525a11f1d5e0c112b07dbb0f939810f6e13 +size 5297 diff --git a/localizer/src/assets/nii/centaurz/Meta_CenTauR.nii.gz b/localizer/src/assets/nii/centaurz/Meta_CenTauR.nii.gz new file mode 100644 index 0000000..a435cee --- /dev/null +++ b/localizer/src/assets/nii/centaurz/Meta_CenTauR.nii.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:66378791ff44896bb6e228f0ed5e60f6061d6a3257a76bb75d5fc359a5c3a1a4 +size 9766 diff --git a/localizer/src/assets/nii/centaurz/TP_CenTauR.nii.gz b/localizer/src/assets/nii/centaurz/TP_CenTauR.nii.gz new file mode 100644 index 0000000..046f7de --- /dev/null +++ b/localizer/src/assets/nii/centaurz/TP_CenTauR.nii.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:87d68d618c9626d3acd1f5141189922efbdb1f8342a24805fc69bbd3777467a9 +size 7223 diff --git a/localizer/src/assets/nii/tspo/notes.md b/localizer/src/assets/nii/tspo/notes.md new file mode 100644 index 0000000..9f18784 --- /dev/null +++ b/localizer/src/assets/nii/tspo/notes.md @@ -0,0 +1,30 @@ +> Rossano SM, Johnson AS, Smith A, et al. Microglia measured by TSPO PET are associated with Alzheimer’s disease pathology and mediate key steps in a disease progression model. *Alzheimer’s & Dementia*. 2024;20(4):2397-2407. doi:[10.1002/alz.13699](https://doi.org/10.1002/alz.13699) + +The following regions are extracted by DCCCSlicer AAL atlas. Both Left and Right are included, though they may not appear in this list simultaneously: + +- hippocampus + - Hippocampus_L +- inferior frontal gyrus + - Frontal_Inf_Oper_R + - Frontal_Inf_Tri_R + - Frontal_Inf_Orb_L +- middle-inferior and superior temporal cortex + - Temporal_Sup_R + - Temporal_Inf_R +- medial temporal cortex + - Temporal_Mid_R +- inferior and superior parietal cortex + - Parietal_Sup_L + - Parietal_Inf_R +- precuneus + - Precuneus_R +- prefrontal cortex + - Frontal_Sup_L + - Frontal_Sup_Medial_L + - Frontal_Sup_Orb_L + - Frontal_Mid_L + - Frontal_Mid_Orb_L + - Frontal_Sup_Medial_L + - Frontal_Med_Orb_L +- posterior cingulate + - Cingulum_Post_L \ No newline at end of file diff --git a/localizer/src/assets/nii/tspo/tspo_AD_related_metaROI.nii b/localizer/src/assets/nii/tspo/tspo_AD_related_metaROI.nii new file mode 100644 index 0000000..0ae26f8 --- /dev/null +++ b/localizer/src/assets/nii/tspo/tspo_AD_related_metaROI.nii @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3825c1def5b992034b39ec218d2866de71cd9d0bf71553edc1e42285e55168f0 +size 1805610 diff --git a/localizer/src/calculators/CenTauRCalculator.cpp b/localizer/src/calculators/CenTauRCalculator.cpp deleted file mode 100644 index c79bbe9..0000000 --- a/localizer/src/calculators/CenTauRCalculator.cpp +++ /dev/null @@ -1,65 +0,0 @@ -#include "CenTauRCalculator.h" -#include "SUVrCalculator.h" -#include "../utils/common.h" -#include -#include - -CenTauRCalculator::CenTauRCalculator(ConfigurationPtr config) : config_(config) {} - -MetricResult CenTauRCalculator::calculate(ImageType::Pointer spatialNormalizedImage) { - std::string voiTemplatePath = config_->getMaskPath("centaur_voi"); - std::string refTemplatePath = config_->getMaskPath("centaur_ref"); - - // Use SUVrCalculator utility method - double suvr = SUVrCalculator::calculateSUVr(spatialNormalizedImage, voiTemplatePath, refTemplatePath); - - MetricResult result; - result.metricName = "CenTauR"; - result.suvr = suvr; - - // Calculate CenTauR values using percentile formula - auto tracerParams = getTracerParameters(); - for (const auto& [tracerName, params] : tracerParams) { - float centaur = (suvr - params.baselineSuvr) / (params.maxSuvr - params.baselineSuvr) * 100; - result.tracerValues[tracerName] = centaur; - } - - return result; -} - -std::string CenTauRCalculator::getName() const { - return "CenTauR"; -} - -std::vector CenTauRCalculator::getSupportedTracers() const { - return {"FTP", "GTP1", "MK6240", "PI2620", "RO948"}; -} - -std::map CenTauRCalculator::getTracerParameters() const { - std::map params; - - // Read from configuration - params["FTP"] = { - config_->getFloat("centaur.tracers.ftp.baseline", 1.06f), - config_->getFloat("centaur.tracers.ftp.max", 2.13f) - }; - params["GTP1"] = { - config_->getFloat("centaur.tracers.gtp1.baseline", 1.08f), - config_->getFloat("centaur.tracers.gtp1.max", 1.69f) - }; - params["MK6240"] = { - config_->getFloat("centaur.tracers.mk6240.baseline", 0.93f), - config_->getFloat("centaur.tracers.mk6240.max", 3.30f) - }; - params["PI2620"] = { - config_->getFloat("centaur.tracers.pi2620.baseline", 1.17f), - config_->getFloat("centaur.tracers.pi2620.max", 2.12f) - }; - params["RO948"] = { - config_->getFloat("centaur.tracers.ro948.baseline", 1.03f), - config_->getFloat("centaur.tracers.ro948.max", 2.40f) - }; - - return params; -} - diff --git a/localizer/src/calculators/CenTauRCalculator.h b/localizer/src/calculators/CenTauRCalculator.h deleted file mode 100644 index 8e2f2d5..0000000 --- a/localizer/src/calculators/CenTauRCalculator.h +++ /dev/null @@ -1,27 +0,0 @@ -#pragma once -#include "../interfaces/IMetricCalculator.h" -#include "../interfaces/IConfiguration.h" - -/** - * @brief CenTauR metric calculator (percentile-based formula) - */ -class CenTauRCalculator : public IMetricCalculator { -public: - explicit CenTauRCalculator(ConfigurationPtr config); - virtual ~CenTauRCalculator() = default; - - MetricResult calculate(ImageType::Pointer spatialNormalizedImage) override; - std::string getName() const override; - std::vector getSupportedTracers() const override; - -private: - ConfigurationPtr config_; - - struct TracerParams { - float baselineSuvr; - float maxSuvr; - }; - - std::map getTracerParameters() const; -}; - diff --git a/localizer/src/calculators/CenTauRzCalculator.cpp b/localizer/src/calculators/CenTauRzCalculator.cpp deleted file mode 100644 index 2c03584..0000000 --- a/localizer/src/calculators/CenTauRzCalculator.cpp +++ /dev/null @@ -1,68 +0,0 @@ -#include "CenTauRzCalculator.h" -#include "SUVrCalculator.h" -#include "../utils/common.h" -#include -#include - -CenTauRzCalculator::CenTauRzCalculator(ConfigurationPtr config) : config_(config) {} - -MetricResult CenTauRzCalculator::calculate(ImageType::Pointer spatialNormalizedImage) { - std::string voiTemplatePath = config_->getMaskPath("centaur_voi"); - std::string refTemplatePath = config_->getMaskPath("centaur_ref"); - - // Use SUVrCalculator utility method - double suvr = SUVrCalculator::calculateSUVr(spatialNormalizedImage, voiTemplatePath, refTemplatePath); - - MetricResult result; - result.metricName = "CenTauRz"; - result.suvr = suvr; - - // Calculate CenTauRz values using z-score formula - auto tracerParams = getTracerParameters(); - for (const auto& [tracerName, params] : tracerParams) { - float centaurz = suvr * params.slope + params.intercept; - result.tracerValues[tracerName] = centaurz; - } - - return result; -} - -std::string CenTauRzCalculator::getName() const { - return "CenTauRz"; -} - -std::vector CenTauRzCalculator::getSupportedTracers() const { - return {"FTP", "GTP1", "MK6240", "PI2620", "RO948", "PM-PBB3"}; -} - -std::map CenTauRzCalculator::getTracerParameters() const { - std::map params; - - // Read from configuration - params["FTP"] = { - config_->getFloat("centaurz.tracers.ftp.slope", 13.63f), - config_->getFloat("centaurz.tracers.ftp.intercept", -15.85f) - }; - params["GTP1"] = { - config_->getFloat("centaurz.tracers.gtp1.slope", 10.67f), - config_->getFloat("centaurz.tracers.gtp1.intercept", -11.92f) - }; - params["MK6240"] = { - config_->getFloat("centaurz.tracers.mk6240.slope", 10.08f), - config_->getFloat("centaurz.tracers.mk6240.intercept", -10.06f) - }; - params["PI2620"] = { - config_->getFloat("centaurz.tracers.pi2620.slope", 8.45f), - config_->getFloat("centaurz.tracers.pi2620.intercept", -9.61f) - }; - params["RO948"] = { - config_->getFloat("centaurz.tracers.ro948.slope", 13.05f), - config_->getFloat("centaurz.tracers.ro948.intercept", -15.57f) - }; - params["PM-PBB3"] = { - config_->getFloat("centaurz.tracers.pmpbb3.slope", 16.73f), - config_->getFloat("centaurz.tracers.pmpbb3.intercept", -15.34f) - }; - - return params; -} diff --git a/localizer/src/calculators/CenTauRzCalculator.h b/localizer/src/calculators/CenTauRzCalculator.h deleted file mode 100644 index c51c18a..0000000 --- a/localizer/src/calculators/CenTauRzCalculator.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once -#include "../interfaces/IMetricCalculator.h" -#include "../interfaces/IConfiguration.h" - -/** - * @brief CenTauRz metric calculator (z-score based formula) - */ -class CenTauRzCalculator : public IMetricCalculator { -public: - explicit CenTauRzCalculator(ConfigurationPtr config); - virtual ~CenTauRzCalculator() = default; - - MetricResult calculate(ImageType::Pointer spatialNormalizedImage) override; - std::string getName() const override; - std::vector getSupportedTracers() const override; - -private: - ConfigurationPtr config_; - - struct TracerParams { - float slope; - float intercept; - }; - - std::map getTracerParameters() const; -}; diff --git a/localizer/src/calculators/CentiloidCalculator.cpp b/localizer/src/calculators/CentiloidCalculator.cpp deleted file mode 100644 index 6618b1d..0000000 --- a/localizer/src/calculators/CentiloidCalculator.cpp +++ /dev/null @@ -1,53 +0,0 @@ -#include "CentiloidCalculator.h" -#include "SUVrCalculator.h" -#include "../utils/common.h" -#include -#include - -CentiloidCalculator::CentiloidCalculator(ConfigurationPtr config) : config_(config) {} - -MetricResult CentiloidCalculator::calculate(ImageType::Pointer spatialNormalizedImage) { - std::string voiTemplatePath = config_->getMaskPath("centiloid_voi"); - std::string refTemplatePath = config_->getMaskPath("whole_cerebral"); - - // Use SUVrCalculator utility method - double suvr = SUVrCalculator::calculateSUVr(spatialNormalizedImage, voiTemplatePath, refTemplatePath); - - MetricResult result; - result.metricName = "Centiloid"; - result.suvr = suvr; - - // Get tracer parameters and calculate Centiloid values for each tracer - auto tracerParams = getTracerParameters(); - for (const auto& [tracerName, params] : tracerParams) { - result.tracerValues[tracerName] = suvr * params.slope + params.intercept; - } - - return result; -} - -std::string CentiloidCalculator::getName() const { - return "Centiloid"; -} - -std::vector CentiloidCalculator::getSupportedTracers() const { - return {"PiB", "FBP", "FBB", "FMM", "NAV"}; -} - -std::map CentiloidCalculator::getTracerParameters() const { - std::map params; - - auto tracers = getSupportedTracers(); - for (const auto& tracer : tracers) { - std::string tracerLower = tracer; - std::transform(tracerLower.begin(), tracerLower.end(), tracerLower.begin(), ::tolower); - - TracerParams tracerParam; - tracerParam.slope = config_->getFloat("centiloid.tracers." + tracerLower + ".slope"); - tracerParam.intercept = config_->getFloat("centiloid.tracers." + tracerLower + ".intercept"); - params[tracer] = tracerParam; - } - - return params; -} - diff --git a/localizer/src/calculators/CentiloidCalculator.h b/localizer/src/calculators/CentiloidCalculator.h deleted file mode 100644 index b9e606e..0000000 --- a/localizer/src/calculators/CentiloidCalculator.h +++ /dev/null @@ -1,27 +0,0 @@ -#pragma once -#include "../interfaces/IMetricCalculator.h" -#include "../interfaces/IConfiguration.h" - -/** - * @brief Centiloid metric calculator - */ -class CentiloidCalculator : public IMetricCalculator { -public: - explicit CentiloidCalculator(ConfigurationPtr config); - virtual ~CentiloidCalculator() = default; - - MetricResult calculate(ImageType::Pointer spatialNormalizedImage) override; - std::string getName() const override; - std::vector getSupportedTracers() const override; - -private: - ConfigurationPtr config_; - - struct TracerParams { - float slope; - float intercept; - }; - - std::map getTracerParameters() const; -}; - diff --git a/localizer/src/calculators/SUVrCalculator.cpp b/localizer/src/calculators/SUVrCalculator.cpp deleted file mode 100644 index 57fade7..0000000 --- a/localizer/src/calculators/SUVrCalculator.cpp +++ /dev/null @@ -1,94 +0,0 @@ -#include "SUVrCalculator.h" -#include "../utils/common.h" -#include -#include - -SUVrCalculator::SUVrCalculator(ConfigurationPtr config) : config_(config) {} - -MetricResult SUVrCalculator::calculate(ImageType::Pointer spatialNormalizedImage) { - MetricResult result; - result.metricName = "SUVr"; - - // Get region configurations - auto regionConfigs = getRegionConfigurations(); - - if (regionConfigs.empty()) { - // Default to centiloid regions if no specific configuration - std::string voiPath = config_->getMaskPath("centiloid_voi"); - std::string refPath = config_->getMaskPath("whole_cerebral"); - result.suvr = calculateSUVr(spatialNormalizedImage, voiPath, refPath); - result.tracerValues["Default"] = static_cast(result.suvr); - } else { - // Calculate SUVr for each configured region pair - double primarySUVr = 0.0; - bool primarySet = false; - - for (const auto& regionConfig : regionConfigs) { - std::string voiPath = config_->getMaskPath(regionConfig.voiMaskKey); - std::string refPath = config_->getMaskPath(regionConfig.refMaskKey); - - double suvr = calculateSUVr(spatialNormalizedImage, voiPath, refPath); - result.tracerValues[regionConfig.description] = static_cast(suvr); - - // Use first configuration as primary SUVr - if (!primarySet) { - result.suvr = suvr; - primarySet = true; - } - } - } - - return result; -} - -std::string SUVrCalculator::getName() const { - return "SUVr"; -} - -std::vector SUVrCalculator::getSupportedTracers() const { - auto regionConfigs = getRegionConfigurations(); - std::vector tracers; - - if (regionConfigs.empty()) { - tracers.push_back("Default"); - } else { - for (const auto& config : regionConfigs) { - tracers.push_back(config.description); - } - } - - return tracers; -} - -double SUVrCalculator::calculateSUVr(ImageType::Pointer spatialNormalizedImage, - const std::string& voiMaskPath, - const std::string& refMaskPath) { - ImageType::Pointer voiTemplate = Common::LoadNii(voiMaskPath); - ImageType::Pointer refTemplate = Common::LoadNii(refMaskPath); - ImageType::Pointer resampledImage = Common::ResampleToMatch(voiTemplate, spatialNormalizedImage); - - double meanVoi = Common::CalculateMeanInMask(resampledImage, voiTemplate); - double meanRef = Common::CalculateMeanInMask(resampledImage, refTemplate); - - return meanVoi / meanRef; -} - -std::vector SUVrCalculator::getRegionConfigurations() const { - std::vector configs; - - // Read from configuration file - // For now, provide some default configurations - // This can be extended to read from config.ini - - // Centiloid regions - if (config_->getMaskPath("centiloid_voi") != "" && config_->getMaskPath("whole_cerebral") != "") { - configs.push_back({"centiloid_voi", "whole_cerebral", "Centiloid_SUVr"}); - } - - // CenTauR regions - if (config_->getMaskPath("centaur_voi") != "" && config_->getMaskPath("centaur_ref") != "") { - configs.push_back({"centaur_voi", "centaur_ref", "CenTauR_SUVr"}); - } - - return configs; -} diff --git a/localizer/src/calculators/SUVrCalculator.h b/localizer/src/calculators/SUVrCalculator.h deleted file mode 100644 index 3f7ccc0..0000000 --- a/localizer/src/calculators/SUVrCalculator.h +++ /dev/null @@ -1,33 +0,0 @@ -#pragma once -#include "../interfaces/IMetricCalculator.h" -#include "../interfaces/IConfiguration.h" - -/** - * @brief SUVr (Standardized Uptake Value ratio) metric calculator - * Base calculator that computes SUVr values using configurable VOI and reference regions - */ -class SUVrCalculator : public IMetricCalculator { -public: - explicit SUVrCalculator(ConfigurationPtr config); - virtual ~SUVrCalculator() = default; - - MetricResult calculate(ImageType::Pointer spatialNormalizedImage) override; - std::string getName() const override; - std::vector getSupportedTracers() const override; - - // Static utility method for other calculators to use - static double calculateSUVr(ImageType::Pointer spatialNormalizedImage, - const std::string& voiMaskPath, - const std::string& refMaskPath); - -private: - ConfigurationPtr config_; - - struct SUVrRegionConfig { - std::string voiMaskKey; - std::string refMaskKey; - std::string description; - }; - - std::vector getRegionConfigurations() const; -}; diff --git a/localizer/src/cli/Commands.cpp b/localizer/src/cli/Commands.cpp deleted file mode 100644 index 78777c0..0000000 --- a/localizer/src/cli/Commands.cpp +++ /dev/null @@ -1,326 +0,0 @@ -#include "Commands.h" -#include "Options.h" -#include "../config/Configuration.h" -#include "../config/Version.h" -#include "../pipeline/ProcessingPipeline.h" -#include "../pipeline/BatchProcessor.h" -#include "../calculators/SUVrCalculator.h" -#include "../utils/common.h" -#include - -// Helper to load config -std::shared_ptr loadConfigurationWithLogging(const std::string& configPath, bool debugMode = false) { - auto config = std::make_shared(); - std::string actualConfigPath = configPath.empty() ? "config.toml" : Configuration::findConfigFile(configPath); - - bool loadSuccess = config->loadFromFile(actualConfigPath); - std::cout << "Loading configuration from: " << actualConfigPath; - - loadSuccess ? std::cout << " [SUCCESS]" << std::endl : - std::cout << " [FAILED] - using default configuration" << std::endl; - - debugMode && (config->printAllConfigurations(), true); - - return config; -} - -SUVrDerivedMetricOptions parseSUVrDerivedMetricOptions(const argparse::ArgumentParser& program, const std::string& metricType) { - SUVrDerivedMetricOptions options; - options.inputPath = program.get("--input"); - options.outputPath = program.get("--output"); - options.configPath = program.get("--config"); - options.includeSUVr = program.get("--suvr"); - options.skipRegistration = program.get("--skip-normalization"); - options.useIterativeRigid = program.get("--iterative"); - options.useManualFOV = program.get("--manual-fov"); - options.enableDebugOutput = program.get("--debug"); - options.batchMode = program.get("--batch"); - options.metricType = metricType; - - setupDebugOutput(options); - return options; -} - -FillStatesCommandOptions parseFillStatesOptions(const argparse::ArgumentParser& program) { - FillStatesCommandOptions options; - options.inputPath = program.get("--input"); - options.outputPath = program.get("--output"); - options.configPath = program.get("--config"); - options.includeSUVr = program.get("--suvr"); - options.skipRegistration = program.get("--skip-normalization"); - options.useIterativeRigid = program.get("--iterative"); - options.useManualFOV = program.get("--manual-fov"); - options.enableDebugOutput = program.get("--debug"); - options.batchMode = program.get("--batch"); - options.metricType = "fillstates"; - options.tracer = program.get("--tracer"); - - setupDebugOutput(options); - return options; -} - -int executeSUVrDerivedMetricCommand(const argparse::ArgumentParser& program, const std::string& metricType, const std::string& fullCommand) { - SUVrDerivedMetricOptions options = parseSUVrDerivedMetricOptions(program, metricType); - - auto config = loadConfigurationWithLogging(options.configPath, options.enableDebugOutput); - - ProcessingOptions procOptions; - procOptions.skipRegistration = options.skipRegistration; - procOptions.useIterativeRigid = options.useIterativeRigid; - procOptions.useManualFOV = options.useManualFOV; - procOptions.enableDebugOutput = options.enableDebugOutput; - procOptions.debugOutputBasePath = options.debugOutputBasePath; - procOptions.selectedMetric = metricType; - - if (options.batchMode) { - auto processor = [config, procOptions](const std::string& inputPath, const std::string& outputPath) -> ProcessingResult { - ProcessingPipeline pipeline(config); - return pipeline.process(inputPath, outputPath, procOptions); - }; - - std::cout << "Starting " << metricType << " batch processing..." << std::endl; - return BatchProcessor::runBatch( - options.inputPath, - options.outputPath, - options.configPath, - SOFTWARE_VERSION, - fullCommand, - options.skipRegistration, - processor - ); - } - - ProcessingPipeline pipeline(config); - std::cout << "Starting " << metricType << " calculation: " << options.inputPath << std::endl; - ProcessingResult result = pipeline.process(options.inputPath, options.outputPath, procOptions); - - std::cout << "\n=== " << metricType << " Results ===" << std::endl; - - for (const auto& metricResult : result.metricResults) { - std::cout << "Metric: " << metricResult.metricName << std::endl; - for (const auto& [tracer, value] : metricResult.tracerValues) { - std::cout << tracer << ": " << value << std::endl; - } - std::cout << std::endl; - if (options.includeSUVr) { - std::cout << "SUVr: " << metricResult.suvr << std::endl << std::endl; - } - } - - std::cout << "Processing completed successfully!" << std::endl; - return EXIT_SUCCESS; -} - -int executeCentiloidCommand(const argparse::ArgumentParser& parser, const std::string& fullCommand) { - return executeSUVrDerivedMetricCommand(parser, "centiloid", fullCommand); -} - -int executeCenTauRCommand(const argparse::ArgumentParser& parser, const std::string& fullCommand) { - return executeSUVrDerivedMetricCommand(parser, "centaur", fullCommand); -} - -int executeCenTauRzCommand(const argparse::ArgumentParser& parser, const std::string& fullCommand) { - return executeSUVrDerivedMetricCommand(parser, "centaurz", fullCommand); -} - -int executeFillStatesCommand(const argparse::ArgumentParser& parser, const std::string& fullCommand) { - FillStatesCommandOptions options = parseFillStatesOptions(parser); - - auto config = loadConfigurationWithLogging(options.configPath, options.enableDebugOutput); - - ProcessingOptions procOptions; - procOptions.skipRegistration = options.skipRegistration; - procOptions.useIterativeRigid = options.useIterativeRigid; - procOptions.useManualFOV = options.useManualFOV; - procOptions.enableDebugOutput = options.enableDebugOutput; - procOptions.debugOutputBasePath = options.debugOutputBasePath; - procOptions.selectedMetric = options.metricType; - procOptions.selectedMetricTracer = options.tracer; - - if (options.batchMode) { - auto processor = [config, procOptions](const std::string& inputPath, const std::string& outputPath) -> ProcessingResult { - ProcessingPipeline pipeline(config); - return pipeline.process(inputPath, outputPath, procOptions); - }; - - std::cout << "Starting " << options.metricType << " batch processing..." << std::endl; - return BatchProcessor::runBatch( - options.inputPath, - options.outputPath, - options.configPath, - SOFTWARE_VERSION, - fullCommand, - options.skipRegistration, - processor - ); - } - - ProcessingPipeline pipeline(config); - std::cout << "Starting " << options.metricType << " calculation: " << options.inputPath << std::endl; - ProcessingResult result = pipeline.process(options.inputPath, options.outputPath, procOptions); - - std::cout << "\n=== " << options.metricType << " Results ===" << std::endl; - - for (const auto& metricResult : result.metricResults) { - std::cout << "Metric: " << metricResult.metricName << std::endl; - for (const auto& [tracer, value] : metricResult.tracerValues) { - std::cout << tracer << ": " << value << std::endl; - } - std::cout << std::endl; - if (options.includeSUVr) { - std::cout << "SUVr: " << metricResult.suvr << std::endl << std::endl; - } - } - - std::cout << "Processing completed successfully!" << std::endl; - return EXIT_SUCCESS; -} - -int executeSUVrCommand(const argparse::ArgumentParser& parser, const std::string& fullCommand) { - SUVrCommandOptions options; - options.inputPath = parser.get("--input"); - options.outputPath = parser.get("--output"); - options.voiMaskPath = parser.get("--voi-mask"); - options.refMaskPath = parser.get("--ref-mask"); - options.configPath = parser.get("--config"); - options.skipRegistration = parser.get("--skip-normalization"); - options.enableDebugOutput = parser.get("--debug"); - options.batchMode = parser.get("--batch"); - - setupDebugOutput(options); - - auto config = loadConfigurationWithLogging(options.configPath, options.enableDebugOutput); - - if (options.batchMode) { - auto processor = [config, options](const std::string& inputPath, const std::string& outputPath) -> ProcessingResult { - ImageType::Pointer inputImage; - if (options.skipRegistration) { - inputImage = Common::LoadNii(inputPath); - } else { - ProcessingOptions procOptions; - procOptions.skipRegistration = false; - procOptions.enableDebugOutput = options.enableDebugOutput; - procOptions.debugOutputBasePath = options.debugOutputBasePath; - procOptions.selectedMetric = ""; - - ProcessingPipeline pipeline(config); - ProcessingResult result = pipeline.process(inputPath, outputPath, procOptions); - inputImage = result.spatiallyNormalizedImage; - } - - double suvr = SUVrCalculator::calculateSUVr(inputImage, options.voiMaskPath, options.refMaskPath); - - ProcessingResult result; - MetricResult metric; - metric.metricName = "CustomSUVr"; - metric.suvr = suvr; - result.metricResults.push_back(metric); - return result; - }; - - std::cout << "Starting SUVr batch processing..." << std::endl; - return BatchProcessor::runBatch( - options.inputPath, - options.outputPath, - options.configPath, - SOFTWARE_VERSION, - fullCommand, - options.skipRegistration, - processor - ); - } - - ImageType::Pointer inputImage; - if (options.skipRegistration) { - inputImage = Common::LoadNii(options.inputPath); - } else { - ProcessingOptions procOptions; - procOptions.skipRegistration = false; - procOptions.enableDebugOutput = options.enableDebugOutput; - procOptions.debugOutputBasePath = options.debugOutputBasePath; - procOptions.selectedMetric = ""; - - ProcessingPipeline pipeline(config); - ProcessingResult result = pipeline.process(options.inputPath, options.outputPath, procOptions); - inputImage = result.spatiallyNormalizedImage; - } - - double suvr = SUVrCalculator::calculateSUVr(inputImage, options.voiMaskPath, options.refMaskPath); - - std::cout << "\n=== SUVr Results ===" << std::endl; - std::cout << "VOI Mask: " << options.voiMaskPath << std::endl; - std::cout << "Reference Mask: " << options.refMaskPath << std::endl; - std::cout << "SUVr: " << suvr << std::endl; - std::cout << "Processing completed successfully!" << std::endl; - - return EXIT_SUCCESS; -} - -int executeNormalizeCommand(const argparse::ArgumentParser& parser) { - NormalizeCommandOptions options; - options.inputPath = parser.get("--input"); - options.outputPath = parser.get("--output"); - options.configPath = parser.get("--config"); - options.normalizationMethod = parser.get("--method"); - options.useIterativeRigid = parser.get("--iterative"); - options.useManualFOV = parser.get("--manual-fov"); - options.enableADNIStyle = parser.get("--ADNI-PET-core"); - options.enableDebugOutput = parser.get("--debug"); - - setupDebugOutput(options); - - auto config = loadConfigurationWithLogging(options.configPath, options.enableDebugOutput); - - ProcessingOptions procOptions; - procOptions.skipRegistration = false; - procOptions.useIterativeRigid = options.useIterativeRigid; - procOptions.useManualFOV = options.useManualFOV; - procOptions.enableADNIStyle = options.enableADNIStyle; - procOptions.enableDebugOutput = options.enableDebugOutput; - procOptions.debugOutputBasePath = options.debugOutputBasePath; - procOptions.selectedMetric = ""; - - ProcessingPipeline pipeline(config); - std::cout << "Starting spatial normalization: " << options.inputPath << std::endl; - ProcessingResult result = pipeline.process(options.inputPath, options.outputPath, procOptions); - - std::cout << "\n=== Normalization Complete ===" << std::endl; - std::cout << "Output image: " << options.outputPath << std::endl; - std::cout << "Processing completed successfully!" << std::endl; - - return EXIT_SUCCESS; -} - -int executeDecoupleCommand(const argparse::ArgumentParser& parser) { - DecoupleCommandOptions options; - options.inputPath = parser.get("--input"); - options.outputPath = parser.get("--output"); - options.modality = parser.get("--modality"); - options.configPath = parser.get("--config"); - options.skipRegistration = parser.get("--skip-normalization"); - options.enableDebugOutput = parser.get("--debug"); - - setupDebugOutput(options); - - auto config = loadConfigurationWithLogging(options.configPath, options.enableDebugOutput); - - ProcessingOptions procOptions; - procOptions.skipRegistration = options.skipRegistration; - procOptions.decoupleModality = options.modality; - procOptions.enableDebugOutput = options.enableDebugOutput; - procOptions.debugOutputBasePath = options.debugOutputBasePath; - procOptions.selectedMetric = ""; - - ProcessingPipeline pipeline(config); - std::cout << "Starting decoupling analysis: " << options.inputPath << std::endl; - ProcessingResult result = pipeline.process(options.inputPath, options.outputPath, procOptions); - - std::cout << "\n=== Decoupling Results ===" << std::endl; - if (result.hasDecoupledResult) { - result.decoupledResult.printResult(); - } - std::cout << "Processing completed successfully!" << std::endl; - - return EXIT_SUCCESS; -} - diff --git a/localizer/src/cli/Commands.h b/localizer/src/cli/Commands.h deleted file mode 100644 index fcc4a82..0000000 --- a/localizer/src/cli/Commands.h +++ /dev/null @@ -1,13 +0,0 @@ -#pragma once -#include -#include - -// Command execution functions -int executeCentiloidCommand(const argparse::ArgumentParser& parser, const std::string& fullCommand); -int executeCenTauRCommand(const argparse::ArgumentParser& parser, const std::string& fullCommand); -int executeCenTauRzCommand(const argparse::ArgumentParser& parser, const std::string& fullCommand); -int executeFillStatesCommand(const argparse::ArgumentParser& parser, const std::string& fullCommand); -int executeSUVrCommand(const argparse::ArgumentParser& parser, const std::string& fullCommand); -int executeNormalizeCommand(const argparse::ArgumentParser& parser); -int executeDecoupleCommand(const argparse::ArgumentParser& parser); - diff --git a/localizer/src/cli/Options.h b/localizer/src/cli/Options.h deleted file mode 100644 index 4845ded..0000000 --- a/localizer/src/cli/Options.h +++ /dev/null @@ -1,74 +0,0 @@ -#pragma once -#include -#include -#include "../config/Version.h" - -/** - * @brief Command-specific option structures - */ -struct BaseCommandOptions { - std::string inputPath; - std::string outputPath; - std::string configPath; - bool enableDebugOutput = false; - std::string debugOutputBasePath; - bool batchMode = false; -}; - -/** - * @brief Spatial normalization related options - */ -struct SpatialNormalizationOptions { - bool useIterativeRigid = false; - bool useManualFOV = false; -}; - -/** - * @brief Options for SUVr-derived metrics (Centiloid, CenTauR, CenTauRz) - */ -struct SUVrDerivedMetricOptions : BaseCommandOptions, SpatialNormalizationOptions { - bool includeSUVr = false; - bool skipRegistration = false; - std::string metricType; // "centiloid", "centaur", "centaurz" -}; - -/** - * @brief Options for custom SUVr calculation - */ -struct SUVrCommandOptions : BaseCommandOptions, SpatialNormalizationOptions { - std::string voiMaskPath; - std::string refMaskPath; - bool skipRegistration = false; -}; - -/** - * @brief Options for spatial normalization only - */ -struct NormalizeCommandOptions : BaseCommandOptions, SpatialNormalizationOptions { - bool enableADNIStyle = false; - std::string normalizationMethod = "rigid_voxelmorph"; -}; - -/** - * @brief Options for decoupling analysis - */ -struct DecoupleCommandOptions : BaseCommandOptions, SpatialNormalizationOptions { - std::string modality; // "abeta" or "tau" - bool skipRegistration = false; -}; - -/** - * @brief Options for fill-states metric - */ -struct FillStatesCommandOptions : SUVrDerivedMetricOptions { - std::string tracer; // "fbp", "fdg", "ftp" -}; - -// Shared Argument Parsers -void addBaseArguments(argparse::ArgumentParser& parser); -void addSpatialNormalizationArguments(argparse::ArgumentParser& parser); -void addSUVrDerivedMetricArguments(argparse::ArgumentParser& parser); -void addFillStatesArguments(argparse::ArgumentParser& parser); - -// Helper functions -void setupDebugOutput(BaseCommandOptions& options); diff --git a/localizer/src/conanfile.py b/localizer/src/conanfile.py index 5fd0c1a..006954c 100644 --- a/localizer/src/conanfile.py +++ b/localizer/src/conanfile.py @@ -3,8 +3,8 @@ class AppConan(ConanFile): - name = "centiloidcalculator" - version = "3.2.0" + name = "DCCCcore" + version = "4.2.3-alpha" settings = "os", "arch", "compiler", "build_type" generators = "CMakeDeps", "CMakeToolchain" @@ -15,13 +15,13 @@ def requirements(self): self.requires("onnxruntime/1.18.1") self.requires("tomlplusplus/3.4.0") self.requires("rapidcsv/8.84") - # ---- conflict resolution: choose one Eigen for the whole graph ---- # If the graph shows ORT wants 3.4.0, prefer: self.requires("eigen/3.4.0") # If you decide to *force* a direct one instead (when you also require eigen directly): # self.requires("eigen/3.4.0", force=True) - + def configure(self): - # https://github.com/conan-io/conan-center-index/issues/29427?utm_source=chatgpt.com + # https://github.com/conan-io/conan-center-index/issues/29427 + # Avoid the current Conan/onetbb hwloc linkage issue seen on master. self.options["hwloc"].shared = True diff --git a/localizer/src/config/Version.h b/localizer/src/config/Version.h deleted file mode 100644 index 24ac3b4..0000000 --- a/localizer/src/config/Version.h +++ /dev/null @@ -1,5 +0,0 @@ -#pragma once -#include - -const std::string SOFTWARE_VERSION = "3.4.0"; - diff --git a/localizer/src/config/Version.h.in b/localizer/src/config/Version.h.in deleted file mode 100644 index ca1af6e..0000000 --- a/localizer/src/config/Version.h.in +++ /dev/null @@ -1,5 +0,0 @@ -#pragma once -#include - -const std::string SOFTWARE_VERSION = "@PROJECT_VERSION@"; - diff --git a/localizer/src/core/README.md b/localizer/src/core/README.md new file mode 100644 index 0000000..3d5230b --- /dev/null +++ b/localizer/src/core/README.md @@ -0,0 +1,48 @@ +# Core Architecture + +## Overview +`core/` is now the base capability layer for the PET tooling. It does not own metric orchestration. Metrics live under `metrics/`, and each metric decides its own workflow through a `CLI + Service + Calculator` structure. + +The current boundary is: +- `core` provides shared runtime capabilities +- `metrics` owns business workflows +- `spatialNormalizations` exposes standalone normalization CLIs + +## What belongs in `core` +- `config/` – TOML-backed configuration loading and version metadata +- `common/` – shared image/path/filesystem/logging utilities and normalization contracts +- `di/` – the lightweight `ServiceContainer` and `buildCoreContainer(...)` +- `interfaces/` – only base capability interfaces such as `IConfiguration` and `ISpatialNormalizer` +- `services/` – reusable services such as `SpatialNormalizationService` and `FileService` +- `normalizers/` and `preprocessing/` – reusable image processing implementations + +## What does not belong in `core` +`core` no longer contains: +- global metric pipeline orchestration +- metric registry / metric dispatch +- metric logic abstractions such as `IMetricLogic` +- metric CLI abstractions +- metric-specific request/response contracts + +Those concepts now live in `metrics/` or `metrics/shared/`. + +## Processing model +1. A metric CLI parses arguments and builds metric-specific options. +2. The CLI creates a base container with `buildCoreContainer(...)`. +3. The metric service resolves shared dependencies from the container. +4. The metric service decides its own workflow: + - whether to normalize + - whether to save intermediate output + - how to run single-file or batch execution + - how to call its calculator +5. The calculator performs the metric-specific computation. + +## Shared services +- `ServiceContainer` – minimal singleton DI container +- `SpatialNormalizationService` – shared normalization entrypoint driven by `SpatialNormalizationRequest` +- `FileService` – shared persistence adapter for normalized images + +## Notes for new work +- If a concern is reusable across many metrics but still metric-facing, prefer `metrics/shared/` over `core/`. +- If a concern is domain-agnostic and useful outside metrics, it belongs in `core/`. +- New metrics should be added as self-contained modules and registered through `metrics/ModuleCatalog`. diff --git a/localizer/src/core/common/Common.h b/localizer/src/core/common/Common.h new file mode 100644 index 0000000..6e8d585 --- /dev/null +++ b/localizer/src/core/common/Common.h @@ -0,0 +1,21 @@ +#pragma once + +#include "NiftiTypes.h" +#include "NiftiIO.h" +#include "ImageOps.h" +#include "PathUtils.h" +#include "Filesystem.h" +#include "Logging.h" +#include "OnnxPath.h" + +namespace Common { + +// Convenience aliases for frequent consumers. +using ImageType = nifti::ImageType; +using DDFType = nifti::DDFType; +using BinaryImageType = nifti::BinaryImageType; +using ReaderType = nifti::ReaderType; + +} // namespace Common + + diff --git a/localizer/src/core/common/Filesystem.cpp b/localizer/src/core/common/Filesystem.cpp new file mode 100644 index 0000000..6a5c958 --- /dev/null +++ b/localizer/src/core/common/Filesystem.cpp @@ -0,0 +1,133 @@ +#include "Filesystem.h" + +#include "PathUtils.h" +#include +#include + +namespace Common::fs { + +bool ensureParentDirectory(const std::string& filePath) { + auto directory = Common::path::fromUtf8(filePath).parent_path(); + if (directory.empty()) { + return true; + } + if (std::filesystem::exists(directory)) { + return std::filesystem::is_directory(directory); + } + std::filesystem::create_directories(directory); + return true; +} + +bool ensureDirectory(const std::filesystem::path& dir) { + if (std::filesystem::exists(dir)) { + return std::filesystem::is_directory(dir); + } + std::filesystem::create_directories(dir); + return true; +} + +bool isDirectoryEmpty(const std::filesystem::path& dir) { + if (!std::filesystem::exists(dir) || !std::filesystem::is_directory(dir)) { + return true; + } + return std::filesystem::is_empty(dir); +} + +bool isNiftiFile(const std::filesystem::path& path) { + auto ext = Common::path::toLower(Common::path::toUtf8(path.extension())); + if (ext == ".nii") { + return true; + } + if (ext == ".gz") { + auto stemExt = Common::path::toLower(Common::path::toUtf8(path.stem().extension())); + return stemExt == ".nii"; + } + return false; +} + +bool isBidsPetNiftiFile(const std::filesystem::path& path) { + if (!isNiftiFile(path)) { + return false; + } + + bool inPetDirectory = false; + for (const auto& part : path.parent_path()) { + if (Common::path::toLower(Common::path::toUtf8(part)) == "pet") { + inPetDirectory = true; + break; + } + } + if (!inPetDirectory) { + return false; + } + + const std::string baseName = baseNameFromNifti(path); + return baseName.rfind("sub-", 0) == 0 + && baseName.size() >= 4 + && baseName.rfind("_pet") == baseName.size() - 4; +} + +std::string baseNameFromNifti(const std::filesystem::path& file) { + if (file.extension() == ".gz" && file.stem().extension() == ".nii") { + return Common::path::toUtf8(file.stem().stem()); + } + return Common::path::toUtf8(file.stem()); +} + +std::vector collectNiftiFiles(const std::filesystem::path& directory) { + std::vector files; + if (!std::filesystem::exists(directory)) { + return files; + } + + for (const auto& entry : std::filesystem::directory_iterator(directory)) { + if (entry.is_regular_file() && isNiftiFile(entry.path())) { + files.push_back(entry.path()); + } + } + std::sort(files.begin(), files.end()); + return files; +} + +std::vector collectBidsPetNiftiFiles(const std::filesystem::path& datasetRoot, + const std::string& pattern) { + std::vector files; + if (!std::filesystem::exists(datasetRoot)) { + return files; + } + + const std::regex nameFilter(pattern); + for (const auto& entry : std::filesystem::recursive_directory_iterator(datasetRoot)) { + if (!entry.is_regular_file() || !isBidsPetNiftiFile(entry.path())) { + continue; + } + + const std::string relativePath = + Common::path::toUtf8(std::filesystem::relative(entry.path(), datasetRoot)); + const std::string filename = Common::path::toUtf8(entry.path().filename()); + const std::string baseName = baseNameFromNifti(entry.path()); + if (std::regex_search(relativePath, nameFilter) + || std::regex_search(filename, nameFilter) + || std::regex_search(baseName, nameFilter)) { + files.push_back(entry.path()); + } + } + std::sort(files.begin(), files.end()); + return files; +} + +std::vector collectInputNiftiFiles(const std::filesystem::path& directory, + const std::string& bidsPattern) { + return bidsPattern.empty() + ? collectNiftiFiles(directory) + : collectBidsPetNiftiFiles(directory, bidsPattern); +} + +std::string buildOutputPath(const std::filesystem::path& inputFile, + const std::filesystem::path& outputDir, + const std::string& suffix) { + std::string baseName = baseNameFromNifti(inputFile); + return Common::path::toUtf8(outputDir / Common::path::fromUtf8(baseName + suffix)); +} + +} // namespace Common::fs diff --git a/localizer/src/core/common/Filesystem.h b/localizer/src/core/common/Filesystem.h new file mode 100644 index 0000000..d6537a8 --- /dev/null +++ b/localizer/src/core/common/Filesystem.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include + +namespace Common::fs { + +bool ensureParentDirectory(const std::string& filePath); +bool ensureDirectory(const std::filesystem::path& dir); +bool isDirectoryEmpty(const std::filesystem::path& dir); +bool isNiftiFile(const std::filesystem::path& path); +bool isBidsPetNiftiFile(const std::filesystem::path& path); +std::string baseNameFromNifti(const std::filesystem::path& file); +std::vector collectNiftiFiles(const std::filesystem::path& directory); +std::vector collectBidsPetNiftiFiles(const std::filesystem::path& datasetRoot, + const std::string& pattern); +std::vector collectInputNiftiFiles(const std::filesystem::path& directory, + const std::string& bidsPattern); +std::string buildOutputPath(const std::filesystem::path& inputFile, + const std::filesystem::path& outputDir, + const std::string& suffix); + +} // namespace Common::fs diff --git a/localizer/src/core/common/ImageOps.cpp b/localizer/src/core/common/ImageOps.cpp new file mode 100644 index 0000000..f7ec46f --- /dev/null +++ b/localizer/src/core/common/ImageOps.cpp @@ -0,0 +1,117 @@ +#include "ImageOps.h" + +#include +#include +#include +#include +#include +#include + +#include + +namespace Common::image { + +void divideVoxelsByValue(ImageType::Pointer image, float divisor) { + itk::ImageRegionIterator it( + image, image->GetLargestPossibleRegion()); + for (it.GoToBegin(); !it.IsAtEnd(); ++it) { + it.Set(it.Get() / divisor); + } +} + +double calculateMeanInMask(ImageType::Pointer image, + ImageType::Pointer mask) { + using LabelStatisticsFilterType = + itk::LabelStatisticsImageFilter; + LabelStatisticsFilterType::Pointer labelStatisticsFilter = LabelStatisticsFilterType::New(); + + labelStatisticsFilter->SetInput(image); + labelStatisticsFilter->SetLabelInput(mask); + labelStatisticsFilter->Update(); + + const unsigned char maskLabel = 1; + if (labelStatisticsFilter->HasLabel(maskLabel)) { + return labelStatisticsFilter->GetMean(maskLabel); + } + + std::cerr << "Mask does not contain the specified label." << std::endl; + return 0.0; +} + +ImageType::Pointer resampleToMatch(ImageType::Pointer referenceImage, + ImageType::Pointer inputImage) { + using ResampleFilterType = itk::ResampleImageFilter; + ResampleFilterType::Pointer resampleFilter = ResampleFilterType::New(); + + resampleFilter->SetInput(inputImage); + resampleFilter->SetSize(referenceImage->GetLargestPossibleRegion().GetSize()); + resampleFilter->SetOutputSpacing(referenceImage->GetSpacing()); + resampleFilter->SetOutputOrigin(referenceImage->GetOrigin()); + resampleFilter->SetOutputDirection(referenceImage->GetDirection()); + + using InterpolatorType = + itk::LinearInterpolateImageFunction; + InterpolatorType::Pointer interpolator = InterpolatorType::New(); + resampleFilter->SetInterpolator(interpolator); + + using TransformType = itk::AffineTransform; + TransformType::Pointer transform = TransformType::New(); + transform->SetIdentity(); + resampleFilter->SetTransform(transform); + + resampleFilter->Update(); + return resampleFilter->GetOutput(); +} + +ImageType::Pointer createImageFromVector(const std::vector& imageData, + ImageType::SizeType size) { + ImageType::Pointer image = ImageType::New(); + ImageType::IndexType start; + start.Fill(0); + ImageType::RegionType region; + region.SetSize(size); + region.SetIndex(start); + + image->SetRegions(region); + image->Allocate(); + image->FillBuffer(0); + + for (size_t x = 0; x < size[0]; ++x) { + for (size_t y = 0; y < size[1]; ++y) { + for (size_t z = 0; z < size[2]; ++z) { + ImageType::IndexType index; + index[0] = static_cast(x); + index[1] = static_cast(y); + index[2] = static_cast(z); + size_t vectorIndex = x * size[1] * size[2] + y * size[2] + z; + image->SetPixel(index, imageData[vectorIndex]); + } + } + } + + return image; +} + +void extractImageData(ImageType::Pointer image, std::vector& imageData) { + ImageType::RegionType region = image->GetLargestPossibleRegion(); + ImageType::SizeType size = region.GetSize(); + + imageData.resize(size[0] * size[1] * size[2]); + for (size_t x = 0; x < size[0]; ++x) { + for (size_t y = 0; y < size[1]; ++y) { + for (size_t z = 0; z < size[2]; ++z) { + ImageType::IndexType index; + index[0] = static_cast(x); + index[1] = static_cast(y); + index[2] = static_cast(z); + float pixelValue = image->GetPixel(index); + imageData[x * size[1] * size[2] + y * size[2] + z] = pixelValue; + } + } + } +} + +} // namespace Common::image + + diff --git a/localizer/src/core/common/ImageOps.h b/localizer/src/core/common/ImageOps.h new file mode 100644 index 0000000..13ea542 --- /dev/null +++ b/localizer/src/core/common/ImageOps.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +#include "NiftiTypes.h" + +namespace Common::image { + +using ImageType = nifti::ImageType; + +void divideVoxelsByValue(ImageType::Pointer image, float divisor); +double calculateMeanInMask(ImageType::Pointer image, + ImageType::Pointer mask); +ImageType::Pointer resampleToMatch(ImageType::Pointer referenceImage, + ImageType::Pointer inputImage); +ImageType::Pointer createImageFromVector(const std::vector& imageData, + ImageType::SizeType size); +void extractImageData(ImageType::Pointer image, std::vector& imageData); + +} // namespace Common::image + + diff --git a/localizer/src/core/common/ImageTypes.h b/localizer/src/core/common/ImageTypes.h new file mode 100644 index 0000000..f23ed90 --- /dev/null +++ b/localizer/src/core/common/ImageTypes.h @@ -0,0 +1,10 @@ +#pragma once + +#include "Common.h" + +using ImageType = Common::ImageType; +using DDFType = Common::DDFType; +using BinaryImageType = Common::BinaryImageType; +using ReaderType = Common::ReaderType; + + diff --git a/localizer/src/core/common/Logging.cpp b/localizer/src/core/common/Logging.cpp new file mode 100644 index 0000000..0f0b843 --- /dev/null +++ b/localizer/src/core/common/Logging.cpp @@ -0,0 +1,13 @@ +#include "Logging.h" + +#include + +namespace Common::log { + +void debug(const std::string& message) { + std::cout << message << std::endl; +} + +} // namespace Common::log + + diff --git a/localizer/src/core/common/Logging.h b/localizer/src/core/common/Logging.h new file mode 100644 index 0000000..907096f --- /dev/null +++ b/localizer/src/core/common/Logging.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +namespace Common::log { + +void debug(const std::string& message); + +} // namespace Common::log + + diff --git a/localizer/src/core/common/NiftiIO.cpp b/localizer/src/core/common/NiftiIO.cpp new file mode 100644 index 0000000..6415d4e --- /dev/null +++ b/localizer/src/core/common/NiftiIO.cpp @@ -0,0 +1,131 @@ +#include "NiftiIO.h" + +#include "PathUtils.h" +#include +#include +#include + +namespace Common::nifti { + +namespace { + +bool containsNonAscii(const std::string& value) { + for (const unsigned char ch : value) { + if (ch > 0x7F) { + return true; + } + } + return false; +} + +std::string niftiExtension(const std::filesystem::path& path) { + if (path.extension() == ".gz" && path.stem().extension() == ".nii") { + return ".nii.gz"; + } + return Common::path::toUtf8(path.extension()); +} + +std::filesystem::path makeAsciiTempPath(const std::filesystem::path& originalPath) { + const std::filesystem::path tempDir = std::filesystem::temp_directory_path() / "dcccslicer_nifti_io"; + std::filesystem::create_directories(tempDir); + + const auto now = std::chrono::steady_clock::now().time_since_epoch().count(); + const std::string extension = niftiExtension(originalPath); + return tempDir / ("dccc_" + std::to_string(now) + extension); +} + +std::string readablePathForItk(const std::filesystem::path& originalPath, + std::filesystem::path& stagingPath, + bool& usesTempCopy) { + stagingPath.clear(); + usesTempCopy = false; + +#ifdef _WIN32 + const std::string originalUtf8 = Common::path::toUtf8(originalPath); + if (containsNonAscii(originalUtf8)) { + stagingPath = makeAsciiTempPath(originalPath); + std::filesystem::copy_file( + originalPath, stagingPath, std::filesystem::copy_options::overwrite_existing); + usesTempCopy = true; + return Common::path::legacyFileName(stagingPath); + } +#endif + return Common::path::legacyFileName(originalPath); +} + +std::string writablePathForItk(const std::filesystem::path& originalPath, + std::filesystem::path& stagingPath, + bool& requiresCopyBack) { + requiresCopyBack = false; + stagingPath.clear(); + +#ifdef _WIN32 + const std::string originalUtf8 = Common::path::toUtf8(originalPath); + if (containsNonAscii(originalUtf8)) { + stagingPath = makeAsciiTempPath(originalPath); + requiresCopyBack = true; + return Common::path::legacyFileName(stagingPath); + } +#endif + return Common::path::legacyFileName(originalPath); +} + +void cleanupTempFile(const std::filesystem::path& path) { + if (path.empty()) { + return; + } + + std::error_code ec; + std::filesystem::remove(path, ec); +} + +} // namespace + +void saveImage(ImageType::Pointer image, const std::string& filename) { + using WriterType = itk::ImageFileWriter; + const std::filesystem::path outputPath = Common::path::fromUtf8(filename); + std::filesystem::path stagingPath; + bool requiresCopyBack = false; + + WriterType::Pointer writer = WriterType::New(); + writer->SetFileName(writablePathForItk(outputPath, stagingPath, requiresCopyBack)); + writer->SetInput(image); + try { + writer->Update(); + + if (requiresCopyBack) { + std::filesystem::copy_file( + stagingPath, outputPath, std::filesystem::copy_options::overwrite_existing); + cleanupTempFile(stagingPath); + } + } catch (...) { + if (requiresCopyBack) { + cleanupTempFile(stagingPath); + } + throw; + } +} + +ImageType::Pointer loadImage(const std::string& filename) { + const std::filesystem::path inputPath = Common::path::fromUtf8(filename); + std::filesystem::path stagingPath; + bool usesTempCopy = false; + const std::string itkPath = readablePathForItk(inputPath, stagingPath, usesTempCopy); + + ReaderType::Pointer reader = ReaderType::New(); + reader->SetFileName(itkPath); + try { + reader->Update(); + if (usesTempCopy) { + cleanupTempFile(stagingPath); + } + return reader->GetOutput(); + } catch (...) { + if (usesTempCopy) { + cleanupTempFile(stagingPath); + } + throw; + } +} + +} // namespace Common::nifti diff --git a/localizer/src/core/common/NiftiIO.h b/localizer/src/core/common/NiftiIO.h new file mode 100644 index 0000000..c663676 --- /dev/null +++ b/localizer/src/core/common/NiftiIO.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +#include "NiftiTypes.h" + +namespace Common::nifti { + +void saveImage(ImageType::Pointer image, const std::string& filename); +ImageType::Pointer loadImage(const std::string& filename); + +} // namespace Common::nifti + + diff --git a/localizer/src/core/common/NiftiTypes.h b/localizer/src/core/common/NiftiTypes.h new file mode 100644 index 0000000..23aecd6 --- /dev/null +++ b/localizer/src/core/common/NiftiTypes.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include +#include + +namespace Common::nifti { + +using ImageType = itk::Image; +using DDFType = itk::Image, 3>; +using BinaryImageType = itk::Image; +using ReaderType = itk::ImageFileReader; + +} // namespace Common::nifti + + diff --git a/localizer/src/core/common/NormalizationContracts.h b/localizer/src/core/common/NormalizationContracts.h new file mode 100644 index 0000000..63620b3 --- /dev/null +++ b/localizer/src/core/common/NormalizationContracts.h @@ -0,0 +1,35 @@ +#pragma once + +#include "ImageTypes.h" +#include + +namespace Pipeline { + +struct SpatialNormalizationOptions { + bool useIterativeRigid = false; + bool useManualFOV = false; + bool rigidOnly = false; + bool enableDebugOutput = false; + std::string debugOutputBasePath; + bool enableAdniPetCore = false; + int maxIterations = 5; + float convergenceThreshold = 2.0f; +}; + +struct SpatialNormalizationRequest { + std::string inputPath; + bool skip = false; + SpatialNormalizationOptions options; +}; + +struct SpatialNormalizationOutput { + ImageType::Pointer rigidAlignedImage; + ImageType::Pointer spatiallyNormalizedImage; +}; + +struct FileSaveRequest { + ImageType::Pointer spatiallyNormalizedImage; + std::string outputPath; +}; + +} // namespace Pipeline diff --git a/localizer/src/core/common/OnnxPath.h b/localizer/src/core/common/OnnxPath.h new file mode 100644 index 0000000..c9d3056 --- /dev/null +++ b/localizer/src/core/common/OnnxPath.h @@ -0,0 +1,63 @@ +#pragma once + +#include "PathUtils.h" +#include +#include +#include +#include + +#include "onnxruntime_cxx_api.h" + +namespace Common::onnx { + +namespace detail { + +inline bool containsNonAscii(const std::string& value) { + for (const unsigned char ch : value) { + if (ch > 0x7F) { + return true; + } + } + return false; +} + +inline std::string stageModelPathIfNeeded(const std::string& path) { +#ifdef _WIN32 + if (containsNonAscii(path)) { + const std::filesystem::path sourcePath = Common::path::fromUtf8(path); + const std::filesystem::path tempDir = + std::filesystem::temp_directory_path() / "dcccslicer_onnx_models"; + std::filesystem::create_directories(tempDir); + + const std::string extension = sourcePath.has_extension() + ? Common::path::toUtf8(sourcePath.extension()) + : std::string{".onnx"}; + const std::string fileName = + "model_" + std::to_string(std::hash{}(path)) + extension; + const std::filesystem::path stagedPath = tempDir / fileName; + + std::filesystem::copy_file( + sourcePath, stagedPath, std::filesystem::copy_options::overwrite_existing); + return Common::path::legacyFileName(stagedPath); + } +#endif + return path; +} + +} // namespace detail + +inline std::basic_string makeOrtPath(const std::string& path) { + const std::string preparedPath = detail::stageModelPathIfNeeded(path); + +#ifdef _WIN32 + if constexpr (std::is_same_v) { + return Common::path::utf8ToWide(preparedPath); + } else { + return preparedPath; + } +#else + return preparedPath; +#endif +} + +} // namespace Common::onnx diff --git a/localizer/src/core/common/PathUtils.cpp b/localizer/src/core/common/PathUtils.cpp new file mode 100644 index 0000000..9d27220 --- /dev/null +++ b/localizer/src/core/common/PathUtils.cpp @@ -0,0 +1,193 @@ +#include "PathUtils.h" + +#include +#include +#include + +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#elif __APPLE__ +#include +#include +#include +#else +#include +#include +#endif + +namespace Common::path { + +namespace { + +std::string normalizeDirectory(const std::filesystem::path& directory) { + if (directory.empty()) { + return {}; + } + return toUtf8(directory); +} + +#ifdef _WIN32 +std::wstring shortPathForExistingPath(const std::filesystem::path& path) { + const std::wstring widePath = path.native(); + const DWORD length = GetShortPathNameW(widePath.c_str(), nullptr, 0); + if (length == 0) { + return {}; + } + + std::wstring buffer(length, L'\0'); + const DWORD written = GetShortPathNameW(widePath.c_str(), buffer.data(), length); + if (written == 0) { + return {}; + } + buffer.resize(written); + return buffer; +} +#endif + +} // namespace + +#ifdef _WIN32 +std::wstring utf8ToWide(const std::string& value) { + if (value.empty()) { + return {}; + } + + const int length = MultiByteToWideChar( + CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), static_cast(value.size()), nullptr, 0); + if (length <= 0) { + throw std::runtime_error("Failed to convert UTF-8 path to UTF-16."); + } + + std::wstring wide(length, L'\0'); + MultiByteToWideChar( + CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), static_cast(value.size()), wide.data(), length); + return wide; +} + +std::string wideToUtf8(const std::wstring& value) { + if (value.empty()) { + return {}; + } + + const int length = WideCharToMultiByte( + CP_UTF8, 0, value.data(), static_cast(value.size()), nullptr, 0, nullptr, nullptr); + if (length <= 0) { + throw std::runtime_error("Failed to convert UTF-16 path to UTF-8."); + } + + std::string utf8(length, '\0'); + WideCharToMultiByte( + CP_UTF8, 0, value.data(), static_cast(value.size()), utf8.data(), length, nullptr, nullptr); + return utf8; +} +#endif + +std::filesystem::path fromUtf8(const std::string& value) { +#ifdef _WIN32 + return std::filesystem::u8path(value); +#else + return std::filesystem::path(value); +#endif +} + +std::string toUtf8(const std::filesystem::path& value) { +#ifdef _WIN32 + return wideToUtf8(value.native()); +#else + return value.string(); +#endif +} + +std::string legacyFileName(const std::filesystem::path& value) { +#ifdef _WIN32 + if (const std::wstring shortPath = shortPathForExistingPath(value); !shortPath.empty()) { + return wideToUtf8(shortPath); + } + + const std::filesystem::path parent = value.parent_path(); + if (!parent.empty()) { + if (const std::wstring shortParent = shortPathForExistingPath(parent); !shortParent.empty()) { + return wideToUtf8((std::filesystem::path(shortParent) / value.filename()).native()); + } + } +#endif + return toUtf8(value); +} + +std::string addSuffix(const std::string& filePath, const std::string& suffix) { + std::filesystem::path path = fromUtf8(filePath); + auto directory = path.parent_path(); + auto stem = toUtf8(path.stem()); + auto extension = toUtf8(path.extension()); + + std::filesystem::path updated = directory / fromUtf8(stem + suffix + extension); + return toUtf8(updated); +} + +std::string toLower(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return value; +} + +std::string executableDirectory() { + std::string executablePath; + +#ifdef _WIN32 + std::wstring buffer(32768, L'\0'); + const DWORD length = GetModuleFileNameW(nullptr, buffer.data(), static_cast(buffer.size())); + if (length > 0) { + buffer.resize(length); + executablePath = wideToUtf8(buffer); + } +#elif __APPLE__ + char path[PATH_MAX]; + uint32_t size = sizeof(path); + if (_NSGetExecutablePath(path, &size) == 0) { + char resolvedPath[PATH_MAX]; + if (realpath(path, resolvedPath) != nullptr) { + executablePath = resolvedPath; + } + } + if (executablePath.empty()) { + executablePath = toUtf8(std::filesystem::current_path()); + } +#else + char result[PATH_MAX]; + ssize_t count = readlink("/proc/self/exe", result, PATH_MAX); + executablePath = std::string(result, (count > 0) ? count : 0); +#endif + + return toUtf8(fromUtf8(executablePath).parent_path()); +} + +std::string deriveDebugBasePath(const std::string& outputPath) { + if (outputPath.empty()) { + return {}; + } + std::filesystem::path filePath = fromUtf8(outputPath); + std::string directory = normalizeDirectory(filePath.parent_path()); + std::string baseName = toUtf8(filePath.stem()); + if (directory.empty()) { + return baseName; + } + return directory + "/" + baseName; +} + +void requireOutputDirectoryExists(const std::string& outputPath) { + if (outputPath.empty()) { + throw std::invalid_argument("Output path must not be empty"); + } + std::filesystem::path directory = fromUtf8(outputPath).parent_path(); + if (directory.empty()) { + return; + } + if (!std::filesystem::exists(directory)) { + throw std::runtime_error("Output directory does not exist: " + toUtf8(directory)); + } +} + +} // namespace Common::path diff --git a/localizer/src/core/common/PathUtils.h b/localizer/src/core/common/PathUtils.h new file mode 100644 index 0000000..4772306 --- /dev/null +++ b/localizer/src/core/common/PathUtils.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include + +namespace Common::path { + +#ifdef _WIN32 +std::wstring utf8ToWide(const std::string& value); +std::string wideToUtf8(const std::wstring& value); +#endif + +std::filesystem::path fromUtf8(const std::string& value); +std::string toUtf8(const std::filesystem::path& value); +std::string legacyFileName(const std::filesystem::path& value); +std::string addSuffix(const std::string& filePath, const std::string& suffix); +std::string toLower(std::string value); +std::string executableDirectory(); +std::string deriveDebugBasePath(const std::string& outputPath); +void requireOutputDirectoryExists(const std::string& outputPath); + +} // namespace Common::path + diff --git a/localizer/src/core/config/ConfigLoader.cpp b/localizer/src/core/config/ConfigLoader.cpp new file mode 100644 index 0000000..f0da20f --- /dev/null +++ b/localizer/src/core/config/ConfigLoader.cpp @@ -0,0 +1,47 @@ +#include "ConfigLoader.h" +#include "Configuration.h" +#include + +namespace Pipeline { + +namespace { + +std::string resolvePath(const std::string& userProvided) { + if (userProvided.empty()) { + return "config.toml"; + } + return Configuration::findConfigFile(userProvided); +} + +std::string logPrefix(const std::string& tag) { + if (tag.empty()) { + return "[config]"; + } + return "[" + tag + "]"; +} + +} // namespace + +ConfigurationPtr loadConfigurationWithLogging(const ConfigurationLoadOptions& options) { + auto configuration = std::make_shared(); + const std::string resolvedPath = resolvePath(options.configPath); + const std::string prefix = logPrefix(options.logTag); + + bool loadSuccess = configuration->loadFromFile(resolvedPath); + std::cout << prefix << " Loading configuration from: " << resolvedPath; + if (loadSuccess) { + std::cout << " [SUCCESS]" << std::endl; + } else { + std::cout << " [FAILED] - using default configuration" << std::endl; + configuration->loadDefaults(); + } + + if (options.enableDebugOutput) { + configuration->printAllConfigurations(); + } + + return configuration; +} + +} // namespace Pipeline + diff --git a/localizer/src/core/config/ConfigLoader.h b/localizer/src/core/config/ConfigLoader.h new file mode 100644 index 0000000..46afa0d --- /dev/null +++ b/localizer/src/core/config/ConfigLoader.h @@ -0,0 +1,16 @@ +#pragma once +#include "../interfaces/IConfiguration.h" +#include + +namespace Pipeline { + +struct ConfigurationLoadOptions { + std::string configPath; + bool enableDebugOutput = false; + std::string logTag; +}; + +ConfigurationPtr loadConfigurationWithLogging(const ConfigurationLoadOptions& options); + +} // namespace Pipeline + diff --git a/localizer/src/config/Configuration.cpp b/localizer/src/core/config/Configuration.cpp similarity index 63% rename from localizer/src/config/Configuration.cpp rename to localizer/src/core/config/Configuration.cpp index 7a8bb92..b11f015 100644 --- a/localizer/src/config/Configuration.cpp +++ b/localizer/src/core/config/Configuration.cpp @@ -1,5 +1,5 @@ #include "Configuration.h" -#include "../utils/common.h" +#include "../common/PathUtils.h" #include #include #include @@ -13,7 +13,7 @@ Configuration::Configuration() { } std::string Configuration::getExecutablePath() const { - return Common::getExecutablePath(); + return Common::path::executableDirectory(); } void Configuration::initializeDefaults() { @@ -27,6 +27,8 @@ void Configuration::initializeDefaults() { // 模板路径 configMap["templates.adni_pet_core"] = "nii/ADNI_empty.nii"; configMap["templates.padded"] = "nii/paddedTemplate.nii"; + configMap["templates.abeta_ns"] = "assets/nii/abeta_load/NS_AV45.nii.gz"; + configMap["templates.abeta_k"] = "assets/nii/abeta_load/K_AV45.nii.gz"; // 掩膜路径 configMap["masks.cerebral_gray"] = "nii/voi_CerebGry_2mm.nii"; @@ -34,6 +36,14 @@ void Configuration::initializeDefaults() { configMap["masks.whole_cerebral"] = "nii/voi_WhlCbl_2mm.nii"; configMap["masks.centaur_voi"] = "nii/CenTauR.nii"; configMap["masks.centaur_ref"] = "nii/voi_CerebGry_tau_2mm.nii"; + configMap["masks.centaurz_mesial_temporal_voi"] = + "assets/nii/centaurz/Mesial_CenTauR.nii.gz"; + configMap["masks.centaurz_meta_temporal_voi"] = + "assets/nii/centaurz/Meta_CenTauR.nii.gz"; + configMap["masks.centaurz_temporo_parietal_voi"] = + "assets/nii/centaurz/TP_CenTauR.nii.gz"; + configMap["masks.centaurz_frontal_voi"] = + "assets/nii/centaurz/Frontal_CenTauR.nii.gz"; // 处理参数 configMap["processing.max_iter"] = "5"; @@ -69,6 +79,58 @@ void Configuration::initializeDefaults() { configMap["fillstates.tracers.ftp.mean"] = "assets/nii/fill_states/fs_FTP_mean.nii.gz"; configMap["fillstates.tracers.ftp.std"] = "assets/nii/fill_states/fs_FTP_std.nii.gz"; configMap["fillstates.tracers.ftp.roi"] = "assets/nii/CenTauR.nii"; + + configMap["centaurz.detailed_regions.mesial_temporal.tracers.ftp.slope"] = "10.42"; + configMap["centaurz.detailed_regions.mesial_temporal.tracers.ftp.intercept"] = "-12.11"; + configMap["centaurz.detailed_regions.mesial_temporal.tracers.gtp1.slope"] = "7.88"; + configMap["centaurz.detailed_regions.mesial_temporal.tracers.gtp1.intercept"] = "-8.75"; + configMap["centaurz.detailed_regions.mesial_temporal.tracers.mk6240.slope"] = "7.28"; + configMap["centaurz.detailed_regions.mesial_temporal.tracers.mk6240.intercept"] = "-7.01"; + configMap["centaurz.detailed_regions.mesial_temporal.tracers.pi2620.slope"] = "6.03"; + configMap["centaurz.detailed_regions.mesial_temporal.tracers.pi2620.intercept"] = "-6.83"; + configMap["centaurz.detailed_regions.mesial_temporal.tracers.ro948.slope"] = "11.76"; + configMap["centaurz.detailed_regions.mesial_temporal.tracers.ro948.intercept"] = "-13.08"; + configMap["centaurz.detailed_regions.mesial_temporal.tracers.pmpbb3.slope"] = "7.97"; + configMap["centaurz.detailed_regions.mesial_temporal.tracers.pmpbb3.intercept"] = "-7.83"; + + configMap["centaurz.detailed_regions.meta_temporal.tracers.ftp.slope"] = "12.95"; + configMap["centaurz.detailed_regions.meta_temporal.tracers.ftp.intercept"] = "-15.37"; + configMap["centaurz.detailed_regions.meta_temporal.tracers.gtp1.slope"] = "9.60"; + configMap["centaurz.detailed_regions.meta_temporal.tracers.gtp1.intercept"] = "-11.10"; + configMap["centaurz.detailed_regions.meta_temporal.tracers.mk6240.slope"] = "9.36"; + configMap["centaurz.detailed_regions.meta_temporal.tracers.mk6240.intercept"] = "-10.60"; + configMap["centaurz.detailed_regions.meta_temporal.tracers.pi2620.slope"] = "7.78"; + configMap["centaurz.detailed_regions.meta_temporal.tracers.pi2620.intercept"] = "-9.33"; + configMap["centaurz.detailed_regions.meta_temporal.tracers.ro948.slope"] = "13.16"; + configMap["centaurz.detailed_regions.meta_temporal.tracers.ro948.intercept"] = "-16.19"; + configMap["centaurz.detailed_regions.meta_temporal.tracers.pmpbb3.slope"] = "11.78"; + configMap["centaurz.detailed_regions.meta_temporal.tracers.pmpbb3.intercept"] = "-11.21"; + + configMap["centaurz.detailed_regions.temporo_parietal.tracers.ftp.slope"] = "13.75"; + configMap["centaurz.detailed_regions.temporo_parietal.tracers.ftp.intercept"] = "-15.92"; + configMap["centaurz.detailed_regions.temporo_parietal.tracers.gtp1.slope"] = "10.84"; + configMap["centaurz.detailed_regions.temporo_parietal.tracers.gtp1.intercept"] = "-12.27"; + configMap["centaurz.detailed_regions.temporo_parietal.tracers.mk6240.slope"] = "9.98"; + configMap["centaurz.detailed_regions.temporo_parietal.tracers.mk6240.intercept"] = "-10.15"; + configMap["centaurz.detailed_regions.temporo_parietal.tracers.pi2620.slope"] = "8.21"; + configMap["centaurz.detailed_regions.temporo_parietal.tracers.pi2620.intercept"] = "-9.52"; + configMap["centaurz.detailed_regions.temporo_parietal.tracers.ro948.slope"] = "13.05"; + configMap["centaurz.detailed_regions.temporo_parietal.tracers.ro948.intercept"] = "-15.62"; + configMap["centaurz.detailed_regions.temporo_parietal.tracers.pmpbb3.slope"] = "16.16"; + configMap["centaurz.detailed_regions.temporo_parietal.tracers.pmpbb3.intercept"] = "-14.68"; + + configMap["centaurz.detailed_regions.frontal.tracers.ftp.slope"] = "11.61"; + configMap["centaurz.detailed_regions.frontal.tracers.ftp.intercept"] = "-13.01"; + configMap["centaurz.detailed_regions.frontal.tracers.gtp1.slope"] = "9.41"; + configMap["centaurz.detailed_regions.frontal.tracers.gtp1.intercept"] = "-9.71"; + configMap["centaurz.detailed_regions.frontal.tracers.mk6240.slope"] = "10.05"; + configMap["centaurz.detailed_regions.frontal.tracers.mk6240.intercept"] = "-8.91"; + configMap["centaurz.detailed_regions.frontal.tracers.pi2620.slope"] = "9.07"; + configMap["centaurz.detailed_regions.frontal.tracers.pi2620.intercept"] = "-9.01"; + configMap["centaurz.detailed_regions.frontal.tracers.ro948.slope"] = "12.61"; + configMap["centaurz.detailed_regions.frontal.tracers.ro948.intercept"] = "-13.45"; + configMap["centaurz.detailed_regions.frontal.tracers.pmpbb3.slope"] = "15.70"; + configMap["centaurz.detailed_regions.frontal.tracers.pmpbb3.intercept"] = "-13.18"; } std::string Configuration::getString(const std::string &key, @@ -148,7 +210,15 @@ void Configuration::setString(const std::string &key, bool Configuration::loadFromFile(const std::string &configPath) { try { - toml::table config = toml::parse_file(configPath); + const std::filesystem::path configFilePath = Common::path::fromUtf8(configPath); + std::ifstream stream(configFilePath, std::ios::binary); + if (!stream.is_open()) { + throw std::runtime_error("Unable to open config file."); + } + + std::stringstream buffer; + buffer << stream.rdbuf(); + toml::table config = toml::parse(buffer.str()); configMap.clear(); // 清空现有配置 listMap.clear(); // 清空数组配置 flattenTomlTable(config); // 递归解析TOML表并扁平化 @@ -175,11 +245,12 @@ std::string Configuration::getTempDirPath() const { // 只有当使用默认的./tmp路径时才自动创建目录 if (tempDir == "./tmp") { - std::string fullTempPath = executableDir + "/" + tempDir; + const std::filesystem::path fullTempPath = + Common::path::fromUtf8(executableDir) / Common::path::fromUtf8(tempDir); if (!std::filesystem::exists(fullTempPath)) { std::filesystem::create_directories(fullTempPath); } - return fullTempPath; + return Common::path::toUtf8(fullTempPath); } // 如果不是默认路径,直接返回配置的路径(可能是绝对路径或其他相对路径) @@ -188,22 +259,26 @@ std::string Configuration::getTempDirPath() const { std::string Configuration::findConfigFile(const std::string &configFileName) { // 先检查当前工作目录 - if (std::filesystem::exists(configFileName)) { + if (std::filesystem::exists(Common::path::fromUtf8(configFileName))) { return configFileName; } // 获取可执行文件目录 - std::string executableDir = Common::getExecutablePath(); + std::string executableDir = Common::path::executableDirectory(); // 检查可执行文件目录下的configs文件夹 - std::string configsPath = executableDir + "/assets/configs/" + configFileName; - if (std::filesystem::exists(configsPath)) { + std::string configsPath = + Common::path::toUtf8(Common::path::fromUtf8(executableDir) / "assets" / "configs" / + Common::path::fromUtf8(configFileName)); + if (std::filesystem::exists(Common::path::fromUtf8(configsPath))) { return configsPath; } // 检查可执行文件目录下的文件 - std::string execDirPath = executableDir + "/" + configFileName; - if (std::filesystem::exists(execDirPath)) { + std::string execDirPath = + Common::path::toUtf8(Common::path::fromUtf8(executableDir) / + Common::path::fromUtf8(configFileName)); + if (std::filesystem::exists(Common::path::fromUtf8(execDirPath))) { return execDirPath; } diff --git a/localizer/src/config/Configuration.h b/localizer/src/core/config/Configuration.h similarity index 100% rename from localizer/src/config/Configuration.h rename to localizer/src/core/config/Configuration.h diff --git a/localizer/src/core/config/Version.h b/localizer/src/core/config/Version.h new file mode 100644 index 0000000..63a828d --- /dev/null +++ b/localizer/src/core/config/Version.h @@ -0,0 +1,4 @@ +#pragma once +#include + +const std::string SOFTWARE_VERSION = "4.2.3-alpha"; diff --git a/localizer/src/core/config/Version.h.in b/localizer/src/core/config/Version.h.in new file mode 100644 index 0000000..782ff96 --- /dev/null +++ b/localizer/src/core/config/Version.h.in @@ -0,0 +1,4 @@ +#pragma once +#include + +const std::string SOFTWARE_VERSION = "@DCCCCORE_SOFTWARE_VERSION@"; diff --git a/localizer/src/core/di/Bootstrap.cpp b/localizer/src/core/di/Bootstrap.cpp new file mode 100644 index 0000000..1b2fe10 --- /dev/null +++ b/localizer/src/core/di/Bootstrap.cpp @@ -0,0 +1,31 @@ +#include "Bootstrap.h" +#include "../config/ConfigLoader.h" +#include "../services/FileService.h" +#include "../services/SpatialNormalizationService.h" + +namespace Pipeline { + +std::shared_ptr buildCoreContainer(const BootstrapOptions& options) { + ConfigurationLoadOptions loadOptions; + loadOptions.configPath = options.configPath; + loadOptions.enableDebugOutput = options.enableConfigDebug; + loadOptions.logTag = options.logTag; + + auto config = loadConfigurationWithLogging(loadOptions); + + auto container = std::make_shared(); + container->registerSingleton([config](auto&) { return config; }); + container->registerSingleton( + [](auto& c) { + auto cfg = c.template resolve(); + return std::make_shared(cfg); + }); + container->registerSingleton( + [](auto&) { + return std::make_shared(); + }); + + return container; +} + +} // namespace Pipeline diff --git a/localizer/src/core/di/Bootstrap.h b/localizer/src/core/di/Bootstrap.h new file mode 100644 index 0000000..c89a0e1 --- /dev/null +++ b/localizer/src/core/di/Bootstrap.h @@ -0,0 +1,17 @@ +#pragma once + +#include +#include +#include "ServiceContainer.h" + +namespace Pipeline { + +struct BootstrapOptions { + std::string configPath; + bool enableConfigDebug = false; + std::string logTag; +}; + +std::shared_ptr buildCoreContainer(const BootstrapOptions& options); + +} // namespace Pipeline diff --git a/localizer/src/core/di/ServiceContainer.h b/localizer/src/core/di/ServiceContainer.h new file mode 100644 index 0000000..40ebc08 --- /dev/null +++ b/localizer/src/core/di/ServiceContainer.h @@ -0,0 +1,48 @@ +#pragma once +#include +#include +#include +#include +#include + +namespace Pipeline { + +class ServiceContainer { +public: + template + void registerSingleton(std::function(ServiceContainer&)> factory) { + const std::type_index key(typeid(TService)); + registrations_[key] = Registration{ + [factory](ServiceContainer& container) -> std::shared_ptr { + return factory(container); + }, + nullptr + }; + } + + template + std::shared_ptr resolve() { + const std::type_index key(typeid(TService)); + auto it = registrations_.find(key); + if (it == registrations_.end()) { + throw std::runtime_error("Service not registered"); + } + + if (!it->second.instance) { + it->second.instance = it->second.factory(*this); + } + + return std::static_pointer_cast(it->second.instance); + } + +private: + struct Registration { + std::function(ServiceContainer&)> factory; + std::shared_ptr instance; + }; + + std::unordered_map registrations_; +}; + +} // namespace Pipeline + diff --git a/localizer/src/interfaces/IConfiguration.h b/localizer/src/core/interfaces/IConfiguration.h similarity index 100% rename from localizer/src/interfaces/IConfiguration.h rename to localizer/src/core/interfaces/IConfiguration.h diff --git a/localizer/src/core/interfaces/ISpatialNormalizationCLI.h b/localizer/src/core/interfaces/ISpatialNormalizationCLI.h new file mode 100644 index 0000000..d289da0 --- /dev/null +++ b/localizer/src/core/interfaces/ISpatialNormalizationCLI.h @@ -0,0 +1,24 @@ +#pragma once +#include +#include +#include + +namespace Pipeline::SpatialNormalization { + +class ISpatialNormalizationCLI { +public: + virtual ~ISpatialNormalizationCLI() = default; + + virtual std::string getSubcommandName() const = 0; + virtual std::string getDescription() const = 0; + + virtual void configureArguments(argparse::ArgumentParser& parser) = 0; + virtual int execute(const argparse::ArgumentParser& parser, const std::string& fullCommand) = 0; +}; + +using SpatialNormalizationCLIPtr = std::shared_ptr; + +} // namespace Pipeline::SpatialNormalization + + + diff --git a/localizer/src/interfaces/ISpatialNormalizer.h b/localizer/src/core/interfaces/ISpatialNormalizer.h similarity index 95% rename from localizer/src/interfaces/ISpatialNormalizer.h rename to localizer/src/core/interfaces/ISpatialNormalizer.h index 2d16609..12259af 100644 --- a/localizer/src/interfaces/ISpatialNormalizer.h +++ b/localizer/src/core/interfaces/ISpatialNormalizer.h @@ -1,5 +1,5 @@ #pragma once -#include "../utils/common.h" +#include "../common/ImageTypes.h" #include #include diff --git a/localizer/src/normalizers/NonlinearRegistrationEngine.cpp b/localizer/src/core/normalizers/NonlinearRegistrationEngine.cpp similarity index 96% rename from localizer/src/normalizers/NonlinearRegistrationEngine.cpp rename to localizer/src/core/normalizers/NonlinearRegistrationEngine.cpp index 50eb824..2f71f63 100644 --- a/localizer/src/normalizers/NonlinearRegistrationEngine.cpp +++ b/localizer/src/core/normalizers/NonlinearRegistrationEngine.cpp @@ -1,12 +1,12 @@ #include "NonlinearRegistrationEngine.h" #include -#include "../utils/onnx_path_utils.h" +#include "../common/OnnxPath.h" NonlinearRegistrationEngine::NonlinearRegistrationEngine(const std::string& modelPath) : env_(ORT_LOGGING_LEVEL_WARNING, "NonlinearRegistration"), session_(nullptr) { Ort::SessionOptions sessionOptions; sessionOptions.SetIntraOpNumThreads(1); - auto ortModelPath = OrtUtils::MakeOrtPath(modelPath); + auto ortModelPath = Common::onnx::makeOrtPath(modelPath); try { session_ = new Ort::Session(env_, ortModelPath.c_str(), sessionOptions); diff --git a/localizer/src/normalizers/NonlinearRegistrationEngine.h b/localizer/src/core/normalizers/NonlinearRegistrationEngine.h similarity index 95% rename from localizer/src/normalizers/NonlinearRegistrationEngine.h rename to localizer/src/core/normalizers/NonlinearRegistrationEngine.h index b725250..3c74087 100644 --- a/localizer/src/normalizers/NonlinearRegistrationEngine.h +++ b/localizer/src/core/normalizers/NonlinearRegistrationEngine.h @@ -1,6 +1,6 @@ #pragma once -#include "../utils/common.h" #include "onnxruntime_cxx_api.h" +#include #include #include diff --git a/localizer/src/core/normalizers/RigidAlignmentNormalizer.cpp b/localizer/src/core/normalizers/RigidAlignmentNormalizer.cpp new file mode 100644 index 0000000..b4c3cd2 --- /dev/null +++ b/localizer/src/core/normalizers/RigidAlignmentNormalizer.cpp @@ -0,0 +1,105 @@ +#include "RigidAlignmentNormalizer.h" + +#include "../common/Common.h" +#include "../preprocessing/ImagePreprocessor.h" + +#include +#include + +RigidAlignmentNormalizer::RigidAlignmentNormalizer(ConfigurationPtr config) + : config_(config) { + if (!config_) { + throw std::invalid_argument("RigidAlignmentNormalizer requires configuration"); + } + + rigidEngine_ = + std::make_unique(config_->getModelPath("rigid")); + paddedTemplate_ = Common::nifti::loadImage(config_->getTemplatePath("padded")); +} + +ImageType::Pointer RigidAlignmentNormalizer::align(ImageType::Pointer inputImage) { + ImageType::Pointer rigidImage = performAlignment(inputImage); + saveDebugImage(rigidImage, "rigid"); + return rigidImage; +} + +ImageType::Pointer RigidAlignmentNormalizer::alignIterative( + ImageType::Pointer inputImage, int maxIter, float threshold) { + ImageType::Pointer currentImage = performAlignment(inputImage, false); + saveDebugImage(currentImage, "rigid0"); + ImageType::PointType lastOrigin = currentImage->GetOrigin(); + + for (int i = 0; i < maxIter; ++i) { + currentImage = performAlignment(currentImage, true); + saveDebugImage(currentImage, "rigid" + std::to_string(i + 1)); + + float originShift = 0; + for (int j = 0; j < 3; ++j) { + originShift += + std::pow(currentImage->GetOrigin()[j] - lastOrigin[j], 2); + } + originShift = std::sqrt(originShift); + + if (originShift < threshold) { + break; + } + lastOrigin = currentImage->GetOrigin(); + } + + return currentImage; +} + +RigidAlignmentNormalizer::AlignmentEstimate RigidAlignmentNormalizer::estimate( + ImageType::Pointer inputImage, bool resampleFirst) { + ImageType::Pointer processedImage = inputImage; + + if (resampleFirst) { + processedImage = Common::image::resampleToMatch(paddedTemplate_, processedImage); + } + + processedImage = ImagePreprocessor::preprocessForRigid(processedImage); + saveDebugImage(processedImage, "rigid_preprocessed"); + + std::vector imageData; + Common::image::extractImageData(processedImage, imageData); + + auto orientation = rigidEngine_->predict(imageData, {1, 1, 64, 64, 64}); + return AlignmentEstimate{processedImage, orientation}; +} + +void RigidAlignmentNormalizer::apply(ImageType::Pointer targetImage, + const AlignmentEstimate& estimate) { + if (!targetImage) { + return; + } + + auto newOriginAndDirection = rigidEngine_->getNewOriginAndDirection( + estimate.preprocessedImage, + targetImage, + estimate.orientation.at("ac"), + estimate.orientation.at("pa"), + estimate.orientation.at("is")); + + targetImage->SetOrigin(std::get<0>(newOriginAndDirection)); + targetImage->SetDirection(std::get<1>(newOriginAndDirection)); +} + +ImageType::Pointer RigidAlignmentNormalizer::performAlignment( + ImageType::Pointer inputImage, bool resampleFirst) { + auto alignmentEstimate = estimate(inputImage, resampleFirst); + apply(inputImage, alignmentEstimate); + return inputImage; +} + +void RigidAlignmentNormalizer::setDebugMode(bool enable, const std::string& basePath) { + debugMode_ = enable; + debugBasePath_ = basePath; +} + +void RigidAlignmentNormalizer::saveDebugImage(ImageType::Pointer image, + const std::string& suffix) { + if (!debugMode_ || debugBasePath_.empty()) { + return; + } + Common::nifti::saveImage(image, debugBasePath_ + "_" + suffix + ".nii"); +} diff --git a/localizer/src/core/normalizers/RigidAlignmentNormalizer.h b/localizer/src/core/normalizers/RigidAlignmentNormalizer.h new file mode 100644 index 0000000..41ec1cd --- /dev/null +++ b/localizer/src/core/normalizers/RigidAlignmentNormalizer.h @@ -0,0 +1,39 @@ +#pragma once + +#include "../common/ImageTypes.h" +#include "../interfaces/IConfiguration.h" +#include "RigidRegistrationEngine.h" + +#include +#include +#include +#include + +class RigidAlignmentNormalizer { +public: + explicit RigidAlignmentNormalizer(ConfigurationPtr config); + + ImageType::Pointer align(ImageType::Pointer inputImage); + ImageType::Pointer alignIterative(ImageType::Pointer inputImage, + int maxIter = 5, + float threshold = 2.0f); + + void setDebugMode(bool enable, const std::string& basePath = ""); + +private: + struct AlignmentEstimate { + ImageType::Pointer preprocessedImage; + std::unordered_map> orientation; + }; + + ConfigurationPtr config_; + std::unique_ptr rigidEngine_; + ImageType::Pointer paddedTemplate_; + bool debugMode_ = false; + std::string debugBasePath_; + + AlignmentEstimate estimate(ImageType::Pointer inputImage, bool resampleFirst = false); + void apply(ImageType::Pointer targetImage, const AlignmentEstimate& estimate); + ImageType::Pointer performAlignment(ImageType::Pointer inputImage, bool resampleFirst = false); + void saveDebugImage(ImageType::Pointer image, const std::string& suffix); +}; diff --git a/localizer/src/normalizers/RigidRegistrationEngine.cpp b/localizer/src/core/normalizers/RigidRegistrationEngine.cpp similarity index 91% rename from localizer/src/normalizers/RigidRegistrationEngine.cpp rename to localizer/src/core/normalizers/RigidRegistrationEngine.cpp index 432af15..354c776 100644 --- a/localizer/src/normalizers/RigidRegistrationEngine.cpp +++ b/localizer/src/core/normalizers/RigidRegistrationEngine.cpp @@ -2,7 +2,7 @@ #include #include #include "itkCrossHelper.h" -#include "../utils/onnx_path_utils.h" +#include "../common/OnnxPath.h" // Helper functions from original Rigid.cpp namespace { @@ -43,7 +43,7 @@ RigidRegistrationEngine::RigidRegistrationEngine(const std::string& modelPath) : env_(ORT_LOGGING_LEVEL_WARNING, "RigidRegistration"), session_(nullptr) { Ort::SessionOptions sessionOptions; sessionOptions.SetIntraOpNumThreads(1); - auto ortModelPath = OrtUtils::MakeOrtPath(modelPath); + auto ortModelPath = Common::onnx::makeOrtPath(modelPath); try { session_ = new Ort::Session(env_, ortModelPath.c_str(), sessionOptions); @@ -65,7 +65,9 @@ std::unordered_map> RigidRegistrationEngine::pre auto input_name_allocated = session_->GetInputNameAllocated(0, allocator); const char* input_name = input_name_allocated.get(); - std::vector input_shape = {1, 1, 64, 64, 64}; + std::vector input_shape = inputShape.empty() + ? std::vector{1, 1, 64, 64, 64} + : inputShape; Ort::MemoryInfo memory_info = Ort::MemoryInfo::CreateCpu(OrtDeviceAllocator, OrtMemTypeDefault); Ort::Value input_tensor = Ort::Value::CreateTensor( @@ -73,7 +75,7 @@ std::unordered_map> RigidRegistrationEngine::pre input_shape.data(), input_shape.size()); // Define output names - const char* output_names[] = {"ac", "nose", "top"}; + const char* output_names[] = {"ac", "pa", "is"}; size_t num_outputs = 3; // Run inference @@ -111,9 +113,11 @@ RigidRegistrationEngine::getNewOriginAndDirection( const std::vector& IS) { std::vector ac = AC, pa = PA, is = IS; - std::for_each(ac.begin(), ac.end(), [](float& x) { x *= 64; }); - std::for_each(pa.begin(), pa.end(), [](float& x) { x *= 99999; }); - std::for_each(is.begin(), is.end(), [](float& x) { x *= 99999; }); + const ImageType::SizeType preprocessedSize = + preprocessedImage->GetLargestPossibleRegion().GetSize(); + for (unsigned int i = 0; i < 3 && i < ac.size(); ++i) { + ac[i] *= static_cast(preprocessedSize[i]); + } const ImageType::DirectionType& preprocessedDirection = preprocessedImage->GetDirection(); @@ -123,7 +127,7 @@ RigidRegistrationEngine::getNewOriginAndDirection( preprocessedImage->GetSpacing(); const ImageType::SpacingType& originalSpacing = originalImage->GetSpacing(); - // Calculate ac nose top in physical space + // Calculate AC and orientation vectors in physical space. auto acPhysical = getPhysicalPoint(ac, preprocessedDirection, preprocessedOrigin, preprocessedSpacing); auto originalVoxelAC = diff --git a/localizer/src/normalizers/RigidRegistrationEngine.h b/localizer/src/core/normalizers/RigidRegistrationEngine.h similarity index 95% rename from localizer/src/normalizers/RigidRegistrationEngine.h rename to localizer/src/core/normalizers/RigidRegistrationEngine.h index 5be27a8..cc5f0bf 100644 --- a/localizer/src/normalizers/RigidRegistrationEngine.h +++ b/localizer/src/core/normalizers/RigidRegistrationEngine.h @@ -1,6 +1,7 @@ #pragma once -#include "../utils/common.h" +#include "../common/ImageTypes.h" #include "onnxruntime_cxx_api.h" +#include #include #include #include diff --git a/localizer/src/core/normalizers/RigidVoxelMorphNormalizer.cpp b/localizer/src/core/normalizers/RigidVoxelMorphNormalizer.cpp new file mode 100644 index 0000000..fd7408e --- /dev/null +++ b/localizer/src/core/normalizers/RigidVoxelMorphNormalizer.cpp @@ -0,0 +1,93 @@ +#include "RigidVoxelMorphNormalizer.h" + +#include + +RigidVoxelMorphNormalizer::RigidVoxelMorphNormalizer(ConfigurationPtr config) + : config_(config) { + if (!config_) { + throw std::invalid_argument("RigidVoxelMorphNormalizer requires configuration"); + } +} + +ImageType::Pointer RigidVoxelMorphNormalizer::normalize(ImageType::Pointer inputImage) { + ImageType::Pointer rigidImage = rigidNormalizer().align(inputImage); + return voxelMorphNormalizer().normalize(rigidImage); +} + +ImageType::Pointer RigidVoxelMorphNormalizer::normalizeRigidOnly( + ImageType::Pointer inputImage) { + return rigidNormalizer().align(inputImage); +} + +ImageType::Pointer RigidVoxelMorphNormalizer::normalizeIterativeRigidOnly( + ImageType::Pointer inputImage, int maxIter, float threshold) { + return rigidNormalizer().alignIterative(inputImage, maxIter, threshold); +} + +ImageType::Pointer RigidVoxelMorphNormalizer::normalizeIterative( + ImageType::Pointer inputImage, int maxIter, float threshold) { + ImageType::Pointer rigidImage = + rigidNormalizer().alignIterative(inputImage, maxIter, threshold); + return voxelMorphNormalizer().normalize(rigidImage); +} + +ImageType::Pointer RigidVoxelMorphNormalizer::normalizeManualFOV( + ImageType::Pointer inputImage) { + return voxelMorphNormalizer().normalize(inputImage); +} + +RigidVoxelMorphNormalizer::NormalizationResult +RigidVoxelMorphNormalizer::normalizeWithIntermediateResults(ImageType::Pointer inputImage) { + NormalizationResult result; + result.rigidAlignedImage = rigidNormalizer().align(inputImage); + result.spatiallyNormalizedImage = + voxelMorphNormalizer().normalize(result.rigidAlignedImage); + return result; +} + +RigidVoxelMorphNormalizer::NormalizationResult +RigidVoxelMorphNormalizer::normalizeIterativeWithIntermediateResults( + ImageType::Pointer inputImage, int maxIter, float threshold) { + NormalizationResult result; + result.rigidAlignedImage = + rigidNormalizer().alignIterative(inputImage, maxIter, threshold); + result.spatiallyNormalizedImage = + voxelMorphNormalizer().normalize(result.rigidAlignedImage); + return result; +} + +std::string RigidVoxelMorphNormalizer::getName() const { + return "RigidVoxelMorph"; +} + +bool RigidVoxelMorphNormalizer::isSupported(const std::string&) const { + return true; +} + +void RigidVoxelMorphNormalizer::setDebugMode(bool enable, + const std::string& basePath) { + debugMode_ = enable; + debugBasePath_ = basePath; + if (rigidNormalizer_) { + rigidNormalizer_->setDebugMode(enable, basePath); + } + if (voxelMorphNormalizer_) { + voxelMorphNormalizer_->setDebugMode(enable, basePath); + } +} + +RigidAlignmentNormalizer& RigidVoxelMorphNormalizer::rigidNormalizer() { + if (!rigidNormalizer_) { + rigidNormalizer_ = std::make_unique(config_); + rigidNormalizer_->setDebugMode(debugMode_, debugBasePath_); + } + return *rigidNormalizer_; +} + +VoxelMorphNormalizer& RigidVoxelMorphNormalizer::voxelMorphNormalizer() { + if (!voxelMorphNormalizer_) { + voxelMorphNormalizer_ = std::make_unique(config_); + voxelMorphNormalizer_->setDebugMode(debugMode_, debugBasePath_); + } + return *voxelMorphNormalizer_; +} diff --git a/localizer/src/normalizers/RigidVoxelMorphNormalizer.h b/localizer/src/core/normalizers/RigidVoxelMorphNormalizer.h similarity index 73% rename from localizer/src/normalizers/RigidVoxelMorphNormalizer.h rename to localizer/src/core/normalizers/RigidVoxelMorphNormalizer.h index a48e703..f0d9001 100644 --- a/localizer/src/normalizers/RigidVoxelMorphNormalizer.h +++ b/localizer/src/core/normalizers/RigidVoxelMorphNormalizer.h @@ -1,7 +1,9 @@ #pragma once #include "../interfaces/ISpatialNormalizer.h" #include "../interfaces/IConfiguration.h" -#include "RegistrationPipeline.h" // Use new registration pipeline +#include "RigidAlignmentNormalizer.h" +#include "VoxelMorphNormalizer.h" +#include /** * @brief Spatial normalizer based on rigid registration and VoxelMorph @@ -16,6 +18,8 @@ class RigidVoxelMorphNormalizer : public ISpatialNormalizer { bool isSupported(const std::string& modality) const override; // Extended functionality + ImageType::Pointer normalizeRigidOnly(ImageType::Pointer inputImage); + ImageType::Pointer normalizeIterativeRigidOnly(ImageType::Pointer inputImage, int maxIter = 5, float threshold = 2.0f); ImageType::Pointer normalizeIterative(ImageType::Pointer inputImage, int maxIter = 5, float threshold = 2.0f); ImageType::Pointer normalizeManualFOV(ImageType::Pointer inputImage); @@ -34,17 +38,13 @@ class RigidVoxelMorphNormalizer : public ISpatialNormalizer { private: ConfigurationPtr config_; - std::unique_ptr registrationPipeline_; - ImageType::Pointer paddedTemplate_; + std::unique_ptr rigidNormalizer_; + std::unique_ptr voxelMorphNormalizer_; // Debug parameters bool debugMode_ = false; std::string debugBasePath_ = ""; - - void initializeModel(); - ImageType::Pointer cropMNI(ImageType::Pointer image); - ImageType::Pointer performRigidAlignment(ImageType::Pointer inputImage, bool resampleFirst = false); - ImageType::Pointer performVoxelMorphWarping(ImageType::Pointer rigidImage); - void saveDebugImage(ImageType::Pointer image, const std::string& suffix); -}; + RigidAlignmentNormalizer& rigidNormalizer(); + VoxelMorphNormalizer& voxelMorphNormalizer(); +}; diff --git a/localizer/src/core/normalizers/VoxelMorphNormalizer.cpp b/localizer/src/core/normalizers/VoxelMorphNormalizer.cpp new file mode 100644 index 0000000..3641fff --- /dev/null +++ b/localizer/src/core/normalizers/VoxelMorphNormalizer.cpp @@ -0,0 +1,84 @@ +#include "VoxelMorphNormalizer.h" + +#include "../common/Common.h" +#include "../preprocessing/ImagePreprocessor.h" + +#include +#include +#include + +VoxelMorphNormalizer::VoxelMorphNormalizer(ConfigurationPtr config) + : config_(config) { + if (!config_) { + throw std::invalid_argument("VoxelMorphNormalizer requires configuration"); + } + + nonlinearEngine_ = std::make_unique( + config_->getModelPath("affine_voxelmorph")); + paddedTemplate_ = Common::nifti::loadImage(config_->getTemplatePath("padded")); +} + +ImageType::Pointer VoxelMorphNormalizer::normalize(ImageType::Pointer rigidImage) { + ImageType::Pointer paddedImage = + Common::image::resampleToMatch(paddedTemplate_, rigidImage); + + paddedImage = ImagePreprocessor::preprocessForVoxelMorph(paddedImage); + saveDebugImage(paddedImage, "elastic_preprocessed"); + + std::vector paddedImageData, paddedTemplateData, paddedOriginalData; + Common::image::extractImageData(paddedImage, paddedImageData); + Common::image::extractImageData(paddedTemplate_, paddedTemplateData); + + ImageType::Pointer paddedOriginalImage = + Common::image::resampleToMatch(paddedTemplate_, rigidImage); + Common::image::extractImageData(paddedOriginalImage, paddedOriginalData); + + auto warpedImageData = nonlinearEngine_->predict( + paddedOriginalData, paddedImageData, paddedTemplateData); + + ImageType::Pointer warpedImage = Common::image::createImageFromVector( + warpedImageData["warped"], paddedImage->GetLargestPossibleRegion().GetSize()); + + warpedImage->SetDirection(paddedTemplate_->GetDirection()); + warpedImage->SetOrigin(paddedTemplate_->GetOrigin()); + warpedImage->SetSpacing(paddedTemplate_->GetSpacing()); + + return cropMNI(warpedImage); +} + +ImageType::Pointer VoxelMorphNormalizer::cropMNI(ImageType::Pointer image) { + ImageType::RegionType cropRegion; + ImageType::RegionType::IndexType start; + start[0] = config_->getInt("processing.crop_mni.start_x", 8); + start[1] = config_->getInt("processing.crop_mni.start_y", 16); + start[2] = config_->getInt("processing.crop_mni.start_z", 8); + + ImageType::RegionType::SizeType size; + size[0] = config_->getInt("processing.crop_mni.size_x", 79); + size[1] = config_->getInt("processing.crop_mni.size_y", 95); + size[2] = config_->getInt("processing.crop_mni.size_z", 79); + + cropRegion.SetSize(size); + cropRegion.SetIndex(start); + + using FilterType = itk::RegionOfInterestImageFilter; + FilterType::Pointer filter = FilterType::New(); + filter->SetRegionOfInterest(cropRegion); + filter->SetInput(image); + filter->Update(); + + return filter->GetOutput(); +} + +void VoxelMorphNormalizer::setDebugMode(bool enable, const std::string& basePath) { + debugMode_ = enable; + debugBasePath_ = basePath; +} + +void VoxelMorphNormalizer::saveDebugImage(ImageType::Pointer image, + const std::string& suffix) { + if (!debugMode_ || debugBasePath_.empty()) { + return; + } + Common::nifti::saveImage(image, debugBasePath_ + "_" + suffix + ".nii"); +} diff --git a/localizer/src/core/normalizers/VoxelMorphNormalizer.h b/localizer/src/core/normalizers/VoxelMorphNormalizer.h new file mode 100644 index 0000000..a737795 --- /dev/null +++ b/localizer/src/core/normalizers/VoxelMorphNormalizer.h @@ -0,0 +1,26 @@ +#pragma once + +#include "../common/ImageTypes.h" +#include "../interfaces/IConfiguration.h" +#include "NonlinearRegistrationEngine.h" + +#include +#include + +class VoxelMorphNormalizer { +public: + explicit VoxelMorphNormalizer(ConfigurationPtr config); + + ImageType::Pointer normalize(ImageType::Pointer rigidImage); + void setDebugMode(bool enable, const std::string& basePath = ""); + +private: + ConfigurationPtr config_; + std::unique_ptr nonlinearEngine_; + ImageType::Pointer paddedTemplate_; + bool debugMode_ = false; + std::string debugBasePath_; + + ImageType::Pointer cropMNI(ImageType::Pointer image); + void saveDebugImage(ImageType::Pointer image, const std::string& suffix); +}; diff --git a/localizer/src/preprocessing/ImagePreprocessor.cpp b/localizer/src/core/preprocessing/ImagePreprocessor.cpp similarity index 88% rename from localizer/src/preprocessing/ImagePreprocessor.cpp rename to localizer/src/core/preprocessing/ImagePreprocessor.cpp index a8c8345..bf105b6 100644 --- a/localizer/src/preprocessing/ImagePreprocessor.cpp +++ b/localizer/src/core/preprocessing/ImagePreprocessor.cpp @@ -24,10 +24,10 @@ ImageType::Pointer ImagePreprocessor::preprocessForVoxelMorph(ImageType::Pointer ImageType::Pointer ImagePreprocessor::clipIntensityPercentiles(ImageType::Pointer image, double lowerPercentile, double upperPercentile) { - auto sortedVoxelValue = getSortedPixelValues(image); + auto voxelValues = getPixelValues(image); - double lowerValue = getPercentileValue(sortedVoxelValue, lowerPercentile); - double upperValue = getPercentileValue(sortedVoxelValue, upperPercentile); + double lowerValue = getPercentileValue(voxelValues, lowerPercentile); + double upperValue = getPercentileValue(voxelValues, upperPercentile); using IntensityWindowingImageFilterType = itk::IntensityWindowingImageFilter; @@ -173,22 +173,27 @@ ImageType::Pointer ImagePreprocessor::resizeImage(ImageType::Pointer image, return resampler->GetOutput(); } -std::vector ImagePreprocessor::getSortedPixelValues(ImageType::Pointer image) { - itk::ImageRegionIterator it(image, image->GetRequestedRegion()); - std::vector pixelValues; +std::vector ImagePreprocessor::getPixelValues(ImageType::Pointer image) { + ImageType::RegionType region = image->GetRequestedRegion(); + itk::ImageRegionIterator it(image, region); + std::vector pixelValues; + pixelValues.reserve(region.GetNumberOfPixels()); for (it.GoToBegin(); !it.IsAtEnd(); ++it) { pixelValues.push_back(it.Get()); } - std::sort(pixelValues.begin(), pixelValues.end()); return pixelValues; } -double ImagePreprocessor::getPercentileValue(const std::vector& sortedValues, - double percentile) { - size_t index = static_cast(percentile * (sortedValues.size() - 1)); - return sortedValues[index]; -} +double ImagePreprocessor::getPercentileValue(std::vector& values, + double percentile) { + if (values.empty()) { + return 0.0; + } + size_t index = static_cast(percentile * (values.size() - 1)); + std::nth_element(values.begin(), values.begin() + index, values.end()); + return values[index]; +} diff --git a/localizer/src/preprocessing/ImagePreprocessor.h b/localizer/src/core/preprocessing/ImagePreprocessor.h similarity index 88% rename from localizer/src/preprocessing/ImagePreprocessor.h rename to localizer/src/core/preprocessing/ImagePreprocessor.h index 6033af5..7a42199 100644 --- a/localizer/src/preprocessing/ImagePreprocessor.h +++ b/localizer/src/core/preprocessing/ImagePreprocessor.h @@ -1,5 +1,5 @@ #pragma once -#include "../utils/common.h" +#include "../common/ImageTypes.h" #include "itkDiscreteGaussianImageFilter.h" #include "itkIntensityWindowingImageFilter.h" #include "itkRescaleIntensityImageFilter.h" @@ -40,8 +40,7 @@ class ImagePreprocessor { const ImageType::SizeType& newSize); // Helper functions - static std::vector getSortedPixelValues(ImageType::Pointer image); - static double getPercentileValue(const std::vector& sortedValues, double percentile); + static std::vector getPixelValues(ImageType::Pointer image); + static double getPercentileValue(std::vector& values, double percentile); }; - diff --git a/localizer/src/core/services/FileService.cpp b/localizer/src/core/services/FileService.cpp new file mode 100644 index 0000000..49fd46a --- /dev/null +++ b/localizer/src/core/services/FileService.cpp @@ -0,0 +1,19 @@ +#include "FileService.h" +#include "../common/Common.h" +#include + +namespace Pipeline { + +void FileService::saveNormalizedImage(const FileSaveRequest& request) { + if (!request.spatiallyNormalizedImage) { + throw std::invalid_argument("FileService requires a spatially normalized image to save"); + } + if (request.outputPath.empty()) { + throw std::invalid_argument("FileService requires a valid output path"); + } + + Common::nifti::saveImage(request.spatiallyNormalizedImage, request.outputPath); +} + +} // namespace Pipeline + diff --git a/localizer/src/core/services/FileService.h b/localizer/src/core/services/FileService.h new file mode 100644 index 0000000..db8e72a --- /dev/null +++ b/localizer/src/core/services/FileService.h @@ -0,0 +1,15 @@ +#pragma once + +#include "IFileService.h" + +namespace Pipeline { + +class FileService : public IFileService { +public: + FileService() = default; + ~FileService() override = default; + + void saveNormalizedImage(const FileSaveRequest& request) override; +}; + +} // namespace Pipeline diff --git a/localizer/src/core/services/IFileService.h b/localizer/src/core/services/IFileService.h new file mode 100644 index 0000000..158401a --- /dev/null +++ b/localizer/src/core/services/IFileService.h @@ -0,0 +1,14 @@ +#pragma once + +#include "../common/NormalizationContracts.h" + +namespace Pipeline { + +class IFileService { +public: + virtual ~IFileService() = default; + + virtual void saveNormalizedImage(const FileSaveRequest& request) = 0; +}; + +} // namespace Pipeline diff --git a/localizer/src/core/services/ISpatialNormalizationService.h b/localizer/src/core/services/ISpatialNormalizationService.h new file mode 100644 index 0000000..70ea088 --- /dev/null +++ b/localizer/src/core/services/ISpatialNormalizationService.h @@ -0,0 +1,14 @@ +#pragma once + +#include "../common/NormalizationContracts.h" + +namespace Pipeline { + +class ISpatialNormalizationService { +public: + virtual ~ISpatialNormalizationService() = default; + + virtual SpatialNormalizationOutput normalize(const SpatialNormalizationRequest& request) = 0; +}; + +} // namespace Pipeline diff --git a/localizer/src/core/services/SpatialNormalizationService.cpp b/localizer/src/core/services/SpatialNormalizationService.cpp new file mode 100644 index 0000000..12758ad --- /dev/null +++ b/localizer/src/core/services/SpatialNormalizationService.cpp @@ -0,0 +1,100 @@ +#include "SpatialNormalizationService.h" +#include "../common/NiftiIO.h" +#include "../common/ImageOps.h" +#include "../normalizers/RigidVoxelMorphNormalizer.h" +#include +#include +#include + +namespace Pipeline { + +SpatialNormalizationService::SpatialNormalizationService(ConfigurationPtr config) + : config_(std::move(config)) { + if (!config_) { + throw std::invalid_argument("SpatialNormalizationService requires a valid configuration"); + } +} + +SpatialNormalizationOutput SpatialNormalizationService::normalize(const SpatialNormalizationRequest& request) { + SpatialNormalizationOutput output; + ImageType::Pointer inputImage = loadInput(request.inputPath); + + if (request.skip) { + output.rigidAlignedImage = inputImage; + output.spatiallyNormalizedImage = inputImage; + } else { + auto normalizer = std::make_shared(config_); + if (request.options.enableDebugOutput) { + normalizer->setDebugMode(true, request.options.debugOutputBasePath); + } + + if (request.options.rigidOnly) { + output.rigidAlignedImage = request.options.useIterativeRigid + ? normalizer->normalizeIterativeRigidOnly( + inputImage, + request.options.maxIterations, + request.options.convergenceThreshold) + : normalizer->normalizeRigidOnly(inputImage); + output.spatiallyNormalizedImage = output.rigidAlignedImage; + } else if (request.options.useManualFOV) { + output.rigidAlignedImage = inputImage; + output.spatiallyNormalizedImage = normalizer->normalizeManualFOV(inputImage); + } else if (request.options.useIterativeRigid) { + auto normResult = normalizer->normalizeIterativeWithIntermediateResults( + inputImage, + request.options.maxIterations, + request.options.convergenceThreshold); + output.rigidAlignedImage = normResult.rigidAlignedImage; + output.spatiallyNormalizedImage = normResult.spatiallyNormalizedImage; + } else { + auto normResult = normalizer->normalizeWithIntermediateResults(inputImage); + output.rigidAlignedImage = normResult.rigidAlignedImage; + output.spatiallyNormalizedImage = normResult.spatiallyNormalizedImage; + } + } + + if (request.options.enableAdniPetCore && !request.options.rigidOnly) { + output.spatiallyNormalizedImage = prepareAdniPetCoreImage( + output.rigidAlignedImage, + output.spatiallyNormalizedImage); + } + + return output; +} + +ImageType::Pointer SpatialNormalizationService::loadInput(const std::string& inputPath) const { + ImageType::Pointer image = Common::nifti::loadImage(inputPath); + if (!image) { + throw std::runtime_error("Failed to load input image: " + inputPath); + } + return image; +} + +ImageType::Pointer SpatialNormalizationService::prepareAdniPetCoreImage(ImageType::Pointer rigidImage, + ImageType::Pointer normalizedImage) const { + if (!rigidImage || !normalizedImage) { + throw std::invalid_argument("ADNI PET Core preparation requires rigid and normalized images"); + } + + ImageType::Pointer cerebellarGray = Common::nifti::loadImage(config_->getMaskPath("cerebral_gray")); + if (!cerebellarGray) { + throw std::runtime_error("Failed to load cerebellar gray mask"); + } + + ImageType::Pointer resampled = Common::image::resampleToMatch(cerebellarGray, normalizedImage); + double meanGray = Common::image::calculateMeanInMask(resampled, cerebellarGray); + if (meanGray <= 0.0) { + throw std::runtime_error("Invalid cerebellar gray mean value for ADNI PET Core normalization"); + } + + ImageType::Pointer adniTemplate = Common::nifti::loadImage(config_->getTemplatePath("adni_pet_core")); + if (!adniTemplate) { + throw std::runtime_error("Failed to load ADNI PET Core template"); + } + + ImageType::Pointer adniStyle = Common::image::resampleToMatch(adniTemplate, rigidImage); + Common::image::divideVoxelsByValue(adniStyle, static_cast(meanGray)); + return adniStyle; +} + +} // namespace Pipeline diff --git a/localizer/src/core/services/SpatialNormalizationService.h b/localizer/src/core/services/SpatialNormalizationService.h new file mode 100644 index 0000000..cc37be9 --- /dev/null +++ b/localizer/src/core/services/SpatialNormalizationService.h @@ -0,0 +1,22 @@ +#pragma once + +#include "ISpatialNormalizationService.h" +#include "../interfaces/IConfiguration.h" + +namespace Pipeline { + +class SpatialNormalizationService : public ISpatialNormalizationService { +public: + explicit SpatialNormalizationService(ConfigurationPtr config); + + SpatialNormalizationOutput normalize(const SpatialNormalizationRequest& request) override; + +private: + ConfigurationPtr config_; + + ImageType::Pointer loadInput(const std::string& inputPath) const; + ImageType::Pointer prepareAdniPetCoreImage(ImageType::Pointer rigidImage, + ImageType::Pointer normalizedImage) const; +}; + +} // namespace Pipeline diff --git a/localizer/src/factories/MetricCalculatorFactory.cpp b/localizer/src/factories/MetricCalculatorFactory.cpp deleted file mode 100644 index dbd7547..0000000 --- a/localizer/src/factories/MetricCalculatorFactory.cpp +++ /dev/null @@ -1,78 +0,0 @@ -#include "MetricCalculatorFactory.h" -#include "../calculators/CentiloidCalculator.h" -#include "../calculators/CenTauRCalculator.h" -#include "../calculators/CenTauRzCalculator.h" -#include "../calculators/SUVrCalculator.h" -#include "../calculators/FillStatesCalculator.h" -#include -#include -#include -#include -#include - -MetricCalculatorPtr MetricCalculatorFactory::create(CalculatorType type, ConfigurationPtr config) { - switch (type) { - case CalculatorType::CENTILOID: - return std::make_shared(config); - case CalculatorType::CENTAUR: - return std::make_shared(config); - case CalculatorType::CENTAURZ: - return std::make_shared(config); - case CalculatorType::SUVR: - return std::make_shared(config); - case CalculatorType::FILL_STATES: - return std::make_shared(config); - default: - throw std::invalid_argument("Unknown metric calculator type"); - } -} - -MetricCalculatorPtr MetricCalculatorFactory::createFromString(const std::string& typeName, ConfigurationPtr config) { - return create(stringToType(typeName), config); -} - -std::vector MetricCalculatorFactory::createAll(ConfigurationPtr config) { - std::vector calculators; - calculators.push_back(create(CalculatorType::SUVR, config)); - calculators.push_back(create(CalculatorType::CENTILOID, config)); - calculators.push_back(create(CalculatorType::CENTAUR, config)); - calculators.push_back(create(CalculatorType::CENTAURZ, config)); - return calculators; -} - -std::vector MetricCalculatorFactory::createSelected(const std::string& selectedMetric, ConfigurationPtr config) { - std::vector calculators; - - // Create the selected calculator - try { - calculators.push_back(createFromString(selectedMetric, config)); - } catch (const std::exception& e) { - std::cerr << "Warning: Failed to create calculator for metric '" << selectedMetric << "': " << e.what() << std::endl; - } - - return calculators; -} - -std::vector MetricCalculatorFactory::getAvailableTypes() { - return {"suvr", "centiloid", "centaur", "centaurz", "fillstates"}; -} - -MetricCalculatorFactory::CalculatorType MetricCalculatorFactory::stringToType(const std::string& typeName) { - std::string lowerName = typeName; - std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), ::tolower); - - if (lowerName == "suvr") { - return CalculatorType::SUVR; - } else if (lowerName == "centiloid") { - return CalculatorType::CENTILOID; - } else if (lowerName == "centaur") { - return CalculatorType::CENTAUR; - } else if (lowerName == "centaurz") { - return CalculatorType::CENTAURZ; - } else if (lowerName == "fillstates") { - return CalculatorType::FILL_STATES; - } - - throw std::invalid_argument("Unsupported metric calculator type: " + typeName); -} - diff --git a/localizer/src/factories/MetricCalculatorFactory.h b/localizer/src/factories/MetricCalculatorFactory.h deleted file mode 100644 index 420ac2c..0000000 --- a/localizer/src/factories/MetricCalculatorFactory.h +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once -#include "../interfaces/IMetricCalculator.h" -#include "../interfaces/IConfiguration.h" -#include -#include -#include -#include - -/** - * @brief Metric calculator factory - */ -class MetricCalculatorFactory { -public: - enum class CalculatorType { - CENTILOID, - CENTAUR, - CENTAURZ, - SUVR, - FILL_STATES, // Z-score based fill-states metric - }; - - static MetricCalculatorPtr create(CalculatorType type, ConfigurationPtr config); - static MetricCalculatorPtr createFromString(const std::string& typeName, ConfigurationPtr config); - static std::vector createAll(ConfigurationPtr config); - static std::vector createSelected(const std::string& selectedMetric, ConfigurationPtr config); - static std::vector getAvailableTypes(); - -private: - static CalculatorType stringToType(const std::string& typeName); -}; - diff --git a/localizer/src/factories/SpatialNormalizerFactory.cpp b/localizer/src/factories/SpatialNormalizerFactory.cpp deleted file mode 100644 index 288b9cc..0000000 --- a/localizer/src/factories/SpatialNormalizerFactory.cpp +++ /dev/null @@ -1,34 +0,0 @@ -#include "SpatialNormalizerFactory.h" -#include "../normalizers/RigidVoxelMorphNormalizer.h" -#include -#include -#include - -SpatialNormalizerPtr SpatialNormalizerFactory::create(NormalizerType type, ConfigurationPtr config) { - switch (type) { - case NormalizerType::RIGID_VOXELMORPH: - return std::make_shared(config); - default: - throw std::invalid_argument("Unknown spatial normalizer type"); - } -} - -SpatialNormalizerPtr SpatialNormalizerFactory::createFromString(const std::string& typeName, ConfigurationPtr config) { - return create(stringToType(typeName), config); -} - -std::vector SpatialNormalizerFactory::getAvailableTypes() { - return {"rigid_voxelmorph"}; -} - -SpatialNormalizerFactory::NormalizerType SpatialNormalizerFactory::stringToType(const std::string& typeName) { - std::string lowerName = typeName; - std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), ::tolower); - - if (lowerName == "rigid_voxelmorph" || lowerName == "default") { - return NormalizerType::RIGID_VOXELMORPH; - } - - throw std::invalid_argument("Unsupported spatial normalizer type: " + typeName); -} - diff --git a/localizer/src/factories/SpatialNormalizerFactory.h b/localizer/src/factories/SpatialNormalizerFactory.h deleted file mode 100644 index f5faf85..0000000 --- a/localizer/src/factories/SpatialNormalizerFactory.h +++ /dev/null @@ -1,28 +0,0 @@ -#pragma once -#include "../interfaces/ISpatialNormalizer.h" -#include "../interfaces/IConfiguration.h" -#include -#include -#include - -/** - * @brief Spatial normalizer factory - */ -class SpatialNormalizerFactory { -public: - enum class NormalizerType { - RIGID_VOXELMORPH, - // Future types can be added here - // AFFINE_ONLY, - // DEFORMABLE_ONLY, - // CUSTOM_MODEL - }; - - static SpatialNormalizerPtr create(NormalizerType type, ConfigurationPtr config); - static SpatialNormalizerPtr createFromString(const std::string& typeName, ConfigurationPtr config); - static std::vector getAvailableTypes(); - -private: - static NormalizerType stringToType(const std::string& typeName); -}; - diff --git a/localizer/src/interfaces/IMetricCalculator.h b/localizer/src/interfaces/IMetricCalculator.h deleted file mode 100644 index e2069f5..0000000 --- a/localizer/src/interfaces/IMetricCalculator.h +++ /dev/null @@ -1,54 +0,0 @@ -#pragma once -#include "../utils/common.h" -#include -#include -#include -#include -#include - -/** - * @brief Semi-quantitative metric calculation result - */ -struct MetricResult { - std::string metricName; - double suvr; - std::map tracerValues; // tracer -> value mapping - - void printResult() const { - std::cout << "Metric: " << metricName << std::endl; - std::cout << "SUVr: " << suvr << std::endl; - for (const auto& [tracer, value] : tracerValues) { - std::cout << tracer << ": " << value << std::endl; - } - std::cout << std::endl; - } -}; - -/** - * @brief Semi-quantitative metric calculator interface - * Defines common interface for all metric calculation algorithms - */ -class IMetricCalculator { -public: - virtual ~IMetricCalculator() = default; - - /** - * @brief Calculate semi-quantitative metrics - * @param spatialNormalizedImage Spatially normalized image - * @return Calculation result - */ - virtual MetricResult calculate(ImageType::Pointer spatialNormalizedImage) = 0; - - /** - * @brief Get calculator name - */ - virtual std::string getName() const = 0; - - /** - * @brief Get list of supported tracers - */ - virtual std::vector getSupportedTracers() const = 0; -}; - -using MetricCalculatorPtr = std::shared_ptr; - diff --git a/localizer/src/main.cpp b/localizer/src/main.cpp index 6398dbc..b459e86 100644 --- a/localizer/src/main.cpp +++ b/localizer/src/main.cpp @@ -1,126 +1,132 @@ -#include "cli/Commands.h" -#include "cli/Options.h" -#include "config/Version.h" +#include +#include "core/common/PathUtils.h" +#include "core/config/Version.h" +#include "metrics/ModuleCatalog.h" +#include "spatialNormalizations/ModuleCatalog.h" #include #include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#endif + +namespace { + +std::string buildFullCommand(const std::vector& args) { + if (args.empty()) { + return {}; + } + std::string command = args[0]; + for (size_t i = 1; i < args.size(); ++i) { + command += " " + args[i]; + } + return command; +} + +#ifdef _WIN32 +std::vector collectUtf8Args(int argc, char* argv[]) { + int wideArgc = 0; + LPWSTR* wideArgv = CommandLineToArgvW(GetCommandLineW(), &wideArgc); + if (!wideArgv) { + std::vector fallback; + fallback.reserve(static_cast(argc)); + for (int i = 0; i < argc; ++i) { + fallback.emplace_back(argv[i]); + } + return fallback; + } + + std::vector args; + args.reserve(static_cast(wideArgc)); + for (int i = 0; i < wideArgc; ++i) { + args.push_back(Common::path::wideToUtf8(wideArgv[i])); + } + LocalFree(wideArgv); + return args; +} +#else +std::vector collectUtf8Args(int argc, char* argv[]) { + std::vector args; + args.reserve(static_cast(argc)); + for (int i = 0; i < argc; ++i) { + args.emplace_back(argv[i]); + } + return args; +} +#endif + +} // namespace int main(int argc, char* argv[]) { itk::NiftiImageIOFactory::RegisterOneFactory(); - - // Reconstruct full command line for logging - std::string fullCommand; - if (argc > 0) { - fullCommand = argv[0]; - for (int i = 1; i < argc; ++i) { - fullCommand += " " + std::string(argv[i]); - } + std::vector utf8Args = collectUtf8Args(argc, argv); + std::vector argparseArgv; + argparseArgv.reserve(utf8Args.size()); + for (auto& arg : utf8Args) { + argparseArgv.push_back(arg.data()); } + const int normalizedArgc = static_cast(argparseArgv.size()); + char** normalizedArgv = argparseArgv.data(); + const std::string fullCommand = buildFullCommand(utf8Args); + try { - // Setup main program with subcommands - argparse::ArgumentParser program("CentiloidCalculator", SOFTWARE_VERSION); + argparse::ArgumentParser program("DCCCcore", SOFTWARE_VERSION); program.add_description("PET image analysis toolkit for quantitative biomarker calculation"); - - // Create subcommand parsers in main function to ensure proper lifetime - // Centiloid subcommand - argparse::ArgumentParser centiloid_cmd("centiloid"); - centiloid_cmd.add_description("Calculate Centiloid metric for amyloid PET images"); - addSUVrDerivedMetricArguments(centiloid_cmd); - - // CenTauR subcommand - argparse::ArgumentParser centaur_cmd("centaur"); - centaur_cmd.add_description("Calculate CenTauR metric for tau PET images"); - addSUVrDerivedMetricArguments(centaur_cmd); - - // CenTauRz subcommand - argparse::ArgumentParser centaurz_cmd("centaurz"); - centaurz_cmd.add_description("Calculate CenTauRz metric for tau PET images (z-score)"); - addSUVrDerivedMetricArguments(centaurz_cmd); - - // Fill-states subcommand - argparse::ArgumentParser fillstates_cmd("fillstates"); - fillstates_cmd.add_description("Calculate fill-states metric for PET images"); - addFillStatesArguments(fillstates_cmd); - - // SUVr subcommand - argparse::ArgumentParser suvr_cmd("suvr"); - suvr_cmd.add_description("Calculate SUVr metric with custom VOI and reference masks"); - addBaseArguments(suvr_cmd); - addSpatialNormalizationArguments(suvr_cmd); - suvr_cmd.add_argument("--voi-mask") - .help("VOI (Volume of Interest) mask path") - .required(); - suvr_cmd.add_argument("--ref-mask") - .help("Reference region mask path") - .required(); - suvr_cmd.add_argument("--skip-normalization") - .help("Skip spatial normalization") - .default_value(false) - .implicit_value(true); - - // Normalize subcommand - argparse::ArgumentParser normalize_cmd("normalize"); - normalize_cmd.add_description("Perform spatial normalization on PET images"); - addBaseArguments(normalize_cmd); - addSpatialNormalizationArguments(normalize_cmd); - normalize_cmd.add_argument("--method") - .help("Normalization method") - .default_value("rigid_voxelmorph"); - normalize_cmd.add_argument("--ADNI-PET-core") - .help("Enable ADNI PET core style processing") - .default_value(false) - .implicit_value(true); - - // Decouple subcommand - argparse::ArgumentParser decouple_cmd("decouple"); - decouple_cmd.add_description("Decouple PET images to extract AD-related components"); - addBaseArguments(decouple_cmd); - addSpatialNormalizationArguments(decouple_cmd); - decouple_cmd.add_argument("--modality") - .help("Modality to decouple") - .choices("abeta", "tau") - .required(); - decouple_cmd.add_argument("--skip-normalization") - .help("Skip spatial normalization") - .default_value(false) - .implicit_value(true); - - // Add subcommands to main program - program.add_subparser(centiloid_cmd); - program.add_subparser(centaur_cmd); - program.add_subparser(centaurz_cmd); - program.add_subparser(fillstates_cmd); - program.add_subparser(suvr_cmd); - program.add_subparser(normalize_cmd); - program.add_subparser(decouple_cmd); - - program.parse_args(argc, argv); - - // Execute appropriate subcommand - if (program.is_subcommand_used("centiloid")) { - return executeCentiloidCommand(centiloid_cmd, fullCommand); - } else if (program.is_subcommand_used("centaur")) { - return executeCenTauRCommand(centaur_cmd, fullCommand); - } else if (program.is_subcommand_used("centaurz")) { - return executeCenTauRzCommand(centaurz_cmd, fullCommand); - } else if (program.is_subcommand_used("fillstates")) { - return executeFillStatesCommand(fillstates_cmd, fullCommand); - } else if (program.is_subcommand_used("suvr")) { - return executeSUVrCommand(suvr_cmd, fullCommand); - } else if (program.is_subcommand_used("normalize")) { - return executeNormalizeCommand(normalize_cmd); - } else if (program.is_subcommand_used("decouple")) { - return executeDecoupleCommand(decouple_cmd); - } else { - std::cerr << "No subcommand specified. Use --help for usage information." << std::endl; - std::cerr << program; - return EXIT_FAILURE; + + auto metricModules = Pipeline::Metrics::buildCLIModules(); + std::vector> metricParsers; + std::vector metricNames; + metricParsers.reserve(metricModules.size()); + metricNames.reserve(metricModules.size()); + + for (const auto& module : metricModules) { + auto parser = std::make_unique(module->getSubcommandName()); + parser->add_description(module->getDescription()); + module->configureArguments(*parser); + program.add_subparser(*parser); + metricNames.push_back(module->getSubcommandName()); + metricParsers.push_back(std::move(parser)); + } + + auto spatialModules = Pipeline::SpatialNormalization::buildCLIModules(); + std::vector> spatialParsers; + std::vector spatialNames; + spatialParsers.reserve(spatialModules.size()); + spatialNames.reserve(spatialModules.size()); + + for (const auto& module : spatialModules) { + auto parser = std::make_unique(module->getSubcommandName()); + parser->add_description(module->getDescription()); + module->configureArguments(*parser); + program.add_subparser(*parser); + spatialNames.push_back(module->getSubcommandName()); + spatialParsers.push_back(std::move(parser)); } - + + program.parse_args(normalizedArgc, normalizedArgv); + + for (size_t i = 0; i < spatialModules.size(); ++i) { + if (program.is_subcommand_used(spatialNames[i])) { + return spatialModules[i]->execute(*spatialParsers[i], fullCommand); + } + } + + for (size_t i = 0; i < metricModules.size(); ++i) { + if (program.is_subcommand_used(metricNames[i])) { + return metricModules[i]->execute(*metricParsers[i], fullCommand); + } + } + + std::cerr << "No subcommand specified. Use --help for usage information." << std::endl; + std::cerr << program; + return EXIT_FAILURE; } catch (const std::exception& e) { std::cerr << "Error: " << e.what() << std::endl; return EXIT_FAILURE; } - - return EXIT_SUCCESS; } diff --git a/localizer/src/metrics/ModuleCatalog.cpp b/localizer/src/metrics/ModuleCatalog.cpp new file mode 100644 index 0000000..89d4f86 --- /dev/null +++ b/localizer/src/metrics/ModuleCatalog.cpp @@ -0,0 +1,41 @@ +#include "ModuleCatalog.h" +#include "abetaindex/AbetaIndexModule.h" +#include "abetaload/AbetaLoadModule.h" +#include "adad/ADADModule.h" +#include "centaur/CenTauRModule.h" +#include "centaurz/CentaurzModule.h" +#include "fillstates/FillStatesModule.h" +#include "list/MetricsCLI.h" +#include "centiloid/CentiloidModule.h" +#include "suvr/SUVrModule.h" +#include "shared/MetricRegistry.h" + +namespace Pipeline::Metrics { + +namespace { + +MetricRegistry buildRegistry() { + MetricRegistry registry; + List::registerModule(registry); + SUVr::registerModule(registry); + Centiloid::registerModule(registry); + Centaur::registerModule(registry); + Centaurz::registerModule(registry); + FillStates::registerModule(registry); + ADAD::registerModule(registry); + AbetaIndex::registerModule(registry); + AbetaLoad::registerModule(registry); + return registry; +} + +} // namespace + +std::vector buildCLIModules() { + return buildRegistry().createCLIModules(); +} + +std::vector listMetricNames() { + return buildRegistry().listMetricNames(); +} + +} // namespace Pipeline::Metrics diff --git a/localizer/src/metrics/ModuleCatalog.h b/localizer/src/metrics/ModuleCatalog.h new file mode 100644 index 0000000..bae7922 --- /dev/null +++ b/localizer/src/metrics/ModuleCatalog.h @@ -0,0 +1,12 @@ +#pragma once + +#include +#include +#include "shared/IMetricCLI.h" + +namespace Pipeline::Metrics { + +std::vector buildCLIModules(); +std::vector listMetricNames(); + +} // namespace Pipeline::Metrics diff --git a/localizer/src/metrics/abetaindex/AbetaIndexCLI.cpp b/localizer/src/metrics/abetaindex/AbetaIndexCLI.cpp new file mode 100644 index 0000000..1afdff2 --- /dev/null +++ b/localizer/src/metrics/abetaindex/AbetaIndexCLI.cpp @@ -0,0 +1,93 @@ +#include "AbetaIndexCLI.h" + +#include "../../core/di/Bootstrap.h" +#include "AbetaIndexService.h" +#include +#include + +namespace Pipeline::Metrics::AbetaIndex { + +namespace { + +void addBaseArguments(argparse::ArgumentParser& parser) { + parser.add_argument("--input") + .help("Input PET image path") + .required(); + parser.add_argument("--output") + .help("Output processed image path") + .required(); + parser.add_argument("--config") + .help("Configuration file path") + .default_value(std::string{"config.toml"}); + parser.add_argument("--debug") + .help("Enable debug mode") + .default_value(false) + .implicit_value(true); + parser.add_argument("--batch") + .help("Enable batch processing mode") + .default_value(false) + .implicit_value(true); + parser.add_argument("--bids") + .help("Treat --input as a PET-BIDS dataset root and process PET files matching this regex") + .default_value(std::string{}); +} + +void addSpatialNormalizationArguments(argparse::ArgumentParser& parser) { + parser.add_argument("-i", "--iterative") + .help("Use iterative rigid transformation") + .default_value(false) + .implicit_value(true); + parser.add_argument("-m", "--manual-fov") + .help("Use manual FOV placement") + .default_value(false) + .implicit_value(true); +} + +class AbetaIndexCLI : public IMetricCLI { +public: + std::string getSubcommandName() const override { + return "abetaindex"; + } + + std::string getDescription() const override { + return "AbetaIndex estimation using AV45 mean/PC1/PC2 templates"; + } + + void configureArguments(argparse::ArgumentParser& parser) override { + addBaseArguments(parser); + addSpatialNormalizationArguments(parser); + parser.add_argument("--skip-normalization") + .help("Skip spatial normalization and calculate metrics directly") + .default_value(false) + .implicit_value(true); + } + + int execute(const argparse::ArgumentParser& parser, const std::string& fullCommand) override { + AbetaIndexCLIOptions options; + options.inputPath = parser.get("--input"); + options.outputPath = parser.get("--output"); + options.configPath = parser.get("--config"); + options.skipRegistration = parser.get("--skip-normalization"); + options.useIterativeRigid = parser.get("--iterative"); + options.useManualFOV = parser.get("--manual-fov"); + options.enableDebugOutput = parser.get("--debug"); + options.batchMode = parser.get("--batch"); + options.bidsPattern = parser.get("--bids"); + + BootstrapOptions bootstrapOptions; + bootstrapOptions.configPath = options.configPath; + bootstrapOptions.enableConfigDebug = options.enableDebugOutput; + bootstrapOptions.logTag = "abetaindex"; + auto container = buildCoreContainer(bootstrapOptions); + auto service = createService(*container); + return service->run(options, fullCommand); + } +}; + +} // namespace + +MetricCLIPtr createCLI() { + return std::make_shared(); +} + +} // namespace Pipeline::Metrics::AbetaIndex diff --git a/localizer/src/metrics/abetaindex/AbetaIndexCLI.h b/localizer/src/metrics/abetaindex/AbetaIndexCLI.h new file mode 100644 index 0000000..c5bc8b6 --- /dev/null +++ b/localizer/src/metrics/abetaindex/AbetaIndexCLI.h @@ -0,0 +1,9 @@ +#pragma once + +#include "../shared/IMetricCLI.h" + +namespace Pipeline::Metrics::AbetaIndex { + +MetricCLIPtr createCLI(); + +} // namespace Pipeline::Metrics::AbetaIndex diff --git a/localizer/src/metrics/abetaindex/AbetaIndexCalculator.cpp b/localizer/src/metrics/abetaindex/AbetaIndexCalculator.cpp new file mode 100644 index 0000000..7bf5edf --- /dev/null +++ b/localizer/src/metrics/abetaindex/AbetaIndexCalculator.cpp @@ -0,0 +1,83 @@ +#include "AbetaIndexCalculator.h" + +#include "../../core/common/Common.h" +#include +#include +#include +#include + +namespace { + +std::string normalizeTracer(std::string tracer) { + for (char& ch : tracer) { + ch = static_cast(std::toupper(static_cast(ch))); + } + return tracer; +} + +} // namespace + +Pipeline::Metrics::MetricResult AbetaIndexCalculator::calculate(const Input& input) const { + if (!input.spatiallyNormalizedImage) { + throw std::invalid_argument("AbetaIndexCalculator requires a spatially normalized image."); + } + + const std::string tracer = normalizeTracer(input.tracer); + if (tracer != "AV45") { + throw std::invalid_argument("AbetaIndex currently supports only the AV45 tracer."); + } + + ImageType::Pointer meanTemplate = Common::nifti::loadImage(input.meanTemplatePath); + ImageType::Pointer pc1Template = Common::nifti::loadImage(input.pc1TemplatePath); + ImageType::Pointer pc2Template = Common::nifti::loadImage(input.pc2TemplatePath); + if (!meanTemplate || !pc1Template || !pc2Template) { + throw std::runtime_error("Failed to load AbetaIndex templates."); + } + + meanTemplate = Common::image::resampleToMatch(input.spatiallyNormalizedImage, meanTemplate); + pc1Template = Common::image::resampleToMatch(input.spatiallyNormalizedImage, pc1Template); + pc2Template = Common::image::resampleToMatch(input.spatiallyNormalizedImage, pc2Template); + + std::vector imageData; + std::vector meanData; + std::vector pc1Data; + std::vector pc2Data; + Common::image::extractImageData(input.spatiallyNormalizedImage, imageData); + Common::image::extractImageData(meanTemplate, meanData); + Common::image::extractImageData(pc1Template, pc1Data); + Common::image::extractImageData(pc2Template, pc2Data); + + if (imageData.size() != meanData.size() || imageData.size() != pc1Data.size() || + imageData.size() != pc2Data.size()) { + throw std::runtime_error("AbetaIndex inputs have mismatched dimensions after resampling."); + } + + double numerator = 0.0; + double denominator = 0.0; + for (size_t i = 0; i < imageData.size(); ++i) { + const double imageValue = static_cast(imageData[i]); + const double meanValue = static_cast(meanData[i]); + const double pc1Value = static_cast(pc1Data[i]); + const double pc2Value = static_cast(pc2Data[i]); + if (!std::isfinite(imageValue) || !std::isfinite(meanValue) || !std::isfinite(pc1Value) || + !std::isfinite(pc2Value)) { + continue; + } + + const double centeredResidual = imageValue - meanValue - pc2Value; + numerator += pc1Value * centeredResidual; + denominator += pc1Value * pc1Value; + } + + if (denominator <= 0.0) { + throw std::runtime_error("AbetaIndex PC1 template has no usable support."); + } + + const double abetaIndex = numerator / denominator; + + Pipeline::Metrics::MetricResult result; + result.metricName = "AbetaIndex"; + result.suvr = abetaIndex; + result.tracerValues["AV45"] = static_cast(abetaIndex); + return result; +} diff --git a/localizer/src/metrics/abetaindex/AbetaIndexCalculator.h b/localizer/src/metrics/abetaindex/AbetaIndexCalculator.h new file mode 100644 index 0000000..c314516 --- /dev/null +++ b/localizer/src/metrics/abetaindex/AbetaIndexCalculator.h @@ -0,0 +1,18 @@ +#pragma once + +#include "../../core/common/ImageTypes.h" +#include "../shared/MetricTypes.h" +#include + +class AbetaIndexCalculator { +public: + struct Input { + ImageType::Pointer spatiallyNormalizedImage; + std::string meanTemplatePath; + std::string pc1TemplatePath; + std::string pc2TemplatePath; + std::string tracer = "AV45"; + }; + + Pipeline::Metrics::MetricResult calculate(const Input& input) const; +}; diff --git a/localizer/src/metrics/abetaindex/AbetaIndexModule.cpp b/localizer/src/metrics/abetaindex/AbetaIndexModule.cpp new file mode 100644 index 0000000..07e3b42 --- /dev/null +++ b/localizer/src/metrics/abetaindex/AbetaIndexModule.cpp @@ -0,0 +1,11 @@ +#include "AbetaIndexModule.h" + +#include "AbetaIndexCLI.h" + +namespace Pipeline::Metrics::AbetaIndex { + +void registerModule(MetricRegistry& registry) { + registry.registerModule({"abetaindex", true, &createCLI}); +} + +} // namespace Pipeline::Metrics::AbetaIndex diff --git a/localizer/src/metrics/abetaindex/AbetaIndexModule.h b/localizer/src/metrics/abetaindex/AbetaIndexModule.h new file mode 100644 index 0000000..8600abb --- /dev/null +++ b/localizer/src/metrics/abetaindex/AbetaIndexModule.h @@ -0,0 +1,9 @@ +#pragma once + +#include "../shared/MetricRegistry.h" + +namespace Pipeline::Metrics::AbetaIndex { + +void registerModule(MetricRegistry& registry); + +} // namespace Pipeline::Metrics::AbetaIndex diff --git a/localizer/src/metrics/abetaindex/AbetaIndexService.cpp b/localizer/src/metrics/abetaindex/AbetaIndexService.cpp new file mode 100644 index 0000000..bbb9320 --- /dev/null +++ b/localizer/src/metrics/abetaindex/AbetaIndexService.cpp @@ -0,0 +1,185 @@ +#include "AbetaIndexService.h" + +#include "../../core/common/Filesystem.h" +#include "../../core/common/PathUtils.h" +#include "../../core/common/NormalizationContracts.h" +#include "../../metrics/shared/BatchRunner.h" +#include "../../metrics/shared/DebugPathHelpers.h" +#include "../../metrics/shared/MetricRunResult.h" +#include "../../metrics/shared/SingleRunner.h" +#include "AbetaIndexCalculator.h" +#include +#include +#include +#include + +namespace Pipeline::Metrics::AbetaIndex { + +namespace { + +constexpr const char* kLogTag = "abetaindex"; +constexpr const char* kBatchOutputSuffix = "_abetaindex.nii"; + +void validateAbetaIndexConfiguration(ConfigurationPtr config) { + if (!config->getBool("abetaindex.enabled", false)) { + throw std::runtime_error( + "AbetaIndex is not enabled for the current config. Use the standard config.toml " + "with abetaindex settings; fast_and_acc is not supported."); + } + + if (config->getString("templates.abeta_index_mean").empty() || + config->getString("templates.abeta_index_pc1").empty() || + config->getString("templates.abeta_index_pc2").empty()) { + throw std::runtime_error( + "AbetaIndex templates are missing from the current config. " + "Expected templates.abeta_index_mean/pc1/pc2."); + } + + const std::string meanPath = config->getTemplatePath("abeta_index_mean"); + const std::string pc1Path = config->getTemplatePath("abeta_index_pc1"); + const std::string pc2Path = config->getTemplatePath("abeta_index_pc2"); + if (!std::filesystem::exists(Common::path::fromUtf8(meanPath)) || + !std::filesystem::is_regular_file(Common::path::fromUtf8(meanPath)) || + !std::filesystem::exists(Common::path::fromUtf8(pc1Path)) || + !std::filesystem::is_regular_file(Common::path::fromUtf8(pc1Path)) || + !std::filesystem::exists(Common::path::fromUtf8(pc2Path)) || + !std::filesystem::is_regular_file(Common::path::fromUtf8(pc2Path))) { + throw std::runtime_error( + "AbetaIndex template files are missing for the active configuration. " + "Please ensure the selected config defines templates.abeta_index_mean/pc1/pc2 " + "and that the referenced files exist."); + } +} + +SpatialNormalizationRequest buildNormalizationRequest(const AbetaIndexCLIOptions& options, + const std::string& inputPath, + const std::string& debugBasePath) { + SpatialNormalizationRequest request; + request.inputPath = inputPath; + request.skip = options.skipRegistration; + request.options.useIterativeRigid = options.useIterativeRigid; + request.options.useManualFOV = options.useManualFOV; + request.options.enableDebugOutput = options.enableDebugOutput; + request.options.debugOutputBasePath = options.enableDebugOutput ? debugBasePath : std::string{}; + return request; +} + +} // namespace + +AbetaIndexService::AbetaIndexService(ConfigurationPtr config, + std::shared_ptr spatialService, + std::shared_ptr fileService) + : config_(std::move(config)), + spatialService_(std::move(spatialService)), + fileService_(std::move(fileService)) { + if (!config_ || !spatialService_ || !fileService_) { + throw std::invalid_argument("AbetaIndexService requires configuration, normalization, and file services"); + } +} + +int AbetaIndexService::run(AbetaIndexCLIOptions options, const std::string& fullCommand) { + Pipeline::Metrics::Shared::configureDerivedDebugBasePath(options); + if (options.batchMode || !options.bidsPattern.empty()) { + return runBatch(options, fullCommand); + } + return runSingle(options, fullCommand); +} + +Metrics::MetricResult AbetaIndexService::executeForImage(const AbetaIndexCLIOptions& options, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugBasePath) const { + validateAbetaIndexConfiguration(config_); + + auto normalizationOutput = + spatialService_->normalize(buildNormalizationRequest(options, inputPath, debugBasePath)); + fileService_->saveNormalizedImage({normalizationOutput.spatiallyNormalizedImage, outputPath}); + + AbetaIndexCalculator::Input input; + input.spatiallyNormalizedImage = normalizationOutput.spatiallyNormalizedImage; + input.meanTemplatePath = config_->getTemplatePath("abeta_index_mean"); + input.pc1TemplatePath = config_->getTemplatePath("abeta_index_pc1"); + input.pc2TemplatePath = config_->getTemplatePath("abeta_index_pc2"); + input.tracer = config_->getString("abetaindex.tracer", "AV45"); + return AbetaIndexCalculator().calculate(input); +} + +int AbetaIndexService::runSingle(const AbetaIndexCLIOptions& options, + const std::string& fullCommand) const { + if (!Common::fs::ensureParentDirectory(options.outputPath)) { + std::cerr << "[" << kLogTag << "] Failed to prepare output directory: " + << options.outputPath << std::endl; + return EXIT_FAILURE; + } + + Pipeline::Metrics::Shared::SingleRunnerHooks hooks; + hooks.logTag = kLogTag; + hooks.resolveDebugBase = [](const AbetaIndexCLIOptions& runnerOptions, const std::string& outputPath) { + return runnerOptions.enableDebugOutput ? Common::path::deriveDebugBasePath(outputPath) + : std::string{}; + }; + hooks.execute = [this](const AbetaIndexCLIOptions& runnerOptions, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugBase) { + Pipeline::Metrics::Shared::MetricRunResult result; + result.metricResults.push_back( + executeForImage(runnerOptions, inputPath, outputPath, debugBase)); + return result; + }; + hooks.logResults = [](const AbetaIndexCLIOptions& runnerOptions, + const Pipeline::Metrics::Shared::MetricRunResult& result) { + std::cout << "\n[" << kLogTag + << "] Spatial normalization complete. Normalized image saved to " + << runnerOptions.outputPath << std::endl; + for (const auto& metric : result.metricResults) { + logMetricResult(metric); + } + }; + return Pipeline::Metrics::Shared::runSingle(options, fullCommand, hooks); +} + +int AbetaIndexService::runBatch(const AbetaIndexCLIOptions& options, + const std::string& fullCommand) const { + Pipeline::Metrics::Shared::BatchRunnerHooks hooks; + hooks.logTag = kLogTag; + hooks.batchOutputSuffix = kBatchOutputSuffix; + hooks.resolveDebugBase = [](const AbetaIndexCLIOptions& runnerOptions, const std::string& outputPath) { + return runnerOptions.enableDebugOutput ? Common::path::deriveDebugBasePath(outputPath) + : std::string{}; + }; + hooks.execute = [this](const AbetaIndexCLIOptions& runnerOptions, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugBase) { + Pipeline::Metrics::Shared::MetricRunResult result; + result.metricResults.push_back( + executeForImage(runnerOptions, inputPath, outputPath, debugBase)); + return result; + }; + hooks.logResults = [](const AbetaIndexCLIOptions&, + const Pipeline::Metrics::Shared::MetricRunResult& result) { + for (const auto& metric : result.metricResults) { + logMetricResult(metric); + } + }; + return Pipeline::Metrics::Shared::runBatch(options, fullCommand, hooks); +} + +void AbetaIndexService::logMetricResult(const Metrics::MetricResult& result) { + std::cout << "\n=== AbetaIndex Results ===" << std::endl; + std::cout << "Metric: " << result.metricName << std::endl; + for (const auto& [label, value] : result.tracerValues) { + std::cout << " " << label << ": " << value << std::endl; + } + std::cout << " AbetaIndex: " << result.suvr << std::endl; +} + +std::shared_ptr createService(ServiceContainer& container) { + auto config = container.resolve(); + auto spatialService = container.resolve(); + auto fileService = container.resolve(); + return std::make_shared(config, spatialService, fileService); +} + +} // namespace Pipeline::Metrics::AbetaIndex diff --git a/localizer/src/metrics/abetaindex/AbetaIndexService.h b/localizer/src/metrics/abetaindex/AbetaIndexService.h new file mode 100644 index 0000000..082ec1c --- /dev/null +++ b/localizer/src/metrics/abetaindex/AbetaIndexService.h @@ -0,0 +1,50 @@ +#pragma once + +#include "../../core/di/ServiceContainer.h" +#include "../../core/interfaces/IConfiguration.h" +#include "../../core/services/IFileService.h" +#include "../../core/services/ISpatialNormalizationService.h" +#include "../shared/MetricTypes.h" +#include +#include + +namespace Pipeline::Metrics::AbetaIndex { + +struct AbetaIndexCLIOptions { + std::string inputPath; + std::string outputPath; + std::string configPath = "config.toml"; + bool skipRegistration = false; + bool useIterativeRigid = false; + bool useManualFOV = false; + bool enableDebugOutput = false; + bool batchMode = false; + std::string bidsPattern; + std::string debugOutputBasePath; +}; + +class AbetaIndexService { +public: + AbetaIndexService(ConfigurationPtr config, + std::shared_ptr spatialService, + std::shared_ptr fileService); + + int run(AbetaIndexCLIOptions options, const std::string& fullCommand); + +private: + Metrics::MetricResult executeForImage(const AbetaIndexCLIOptions& options, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugBasePath) const; + int runSingle(const AbetaIndexCLIOptions& options, const std::string& fullCommand) const; + int runBatch(const AbetaIndexCLIOptions& options, const std::string& fullCommand) const; + static void logMetricResult(const Metrics::MetricResult& result); + + ConfigurationPtr config_; + std::shared_ptr spatialService_; + std::shared_ptr fileService_; +}; + +std::shared_ptr createService(ServiceContainer& container); + +} // namespace Pipeline::Metrics::AbetaIndex diff --git a/localizer/src/metrics/abetaload/AbetaLoadCLI.cpp b/localizer/src/metrics/abetaload/AbetaLoadCLI.cpp new file mode 100644 index 0000000..fab69f4 --- /dev/null +++ b/localizer/src/metrics/abetaload/AbetaLoadCLI.cpp @@ -0,0 +1,93 @@ +#include "AbetaLoadCLI.h" + +#include "../../core/di/Bootstrap.h" +#include "AbetaLoadService.h" +#include +#include + +namespace Pipeline::Metrics::AbetaLoad { + +namespace { + +void addBaseArguments(argparse::ArgumentParser& parser) { + parser.add_argument("--input") + .help("Input PET image path") + .required(); + parser.add_argument("--output") + .help("Output processed image path") + .required(); + parser.add_argument("--config") + .help("Configuration file path") + .default_value(std::string{"config.toml"}); + parser.add_argument("--debug") + .help("Enable debug mode") + .default_value(false) + .implicit_value(true); + parser.add_argument("--batch") + .help("Enable batch processing mode") + .default_value(false) + .implicit_value(true); + parser.add_argument("--bids") + .help("Treat --input as a PET-BIDS dataset root and process PET files matching this regex") + .default_value(std::string{}); +} + +void addSpatialNormalizationArguments(argparse::ArgumentParser& parser) { + parser.add_argument("-i", "--iterative") + .help("Use iterative rigid transformation") + .default_value(false) + .implicit_value(true); + parser.add_argument("-m", "--manual-fov") + .help("Use manual FOV placement") + .default_value(false) + .implicit_value(true); +} + +class AbetaLoadCLI : public IMetricCLI { +public: + std::string getSubcommandName() const override { + return "abetaload"; + } + + std::string getDescription() const override { + return "AbetaLoad estimation using NS/K decomposition"; + } + + void configureArguments(argparse::ArgumentParser& parser) override { + addBaseArguments(parser); + addSpatialNormalizationArguments(parser); + parser.add_argument("--skip-normalization") + .help("Skip spatial normalization and calculate metrics directly") + .default_value(false) + .implicit_value(true); + } + + int execute(const argparse::ArgumentParser& parser, const std::string& fullCommand) override { + AbetaLoadCLIOptions options; + options.inputPath = parser.get("--input"); + options.outputPath = parser.get("--output"); + options.configPath = parser.get("--config"); + options.skipRegistration = parser.get("--skip-normalization"); + options.useIterativeRigid = parser.get("--iterative"); + options.useManualFOV = parser.get("--manual-fov"); + options.enableDebugOutput = parser.get("--debug"); + options.batchMode = parser.get("--batch"); + options.bidsPattern = parser.get("--bids"); + + BootstrapOptions bootstrapOptions; + bootstrapOptions.configPath = options.configPath; + bootstrapOptions.enableConfigDebug = options.enableDebugOutput; + bootstrapOptions.logTag = "abetaload"; + auto container = buildCoreContainer(bootstrapOptions); + auto service = createService(*container); + return service->run(options, fullCommand); + } +}; + +} // namespace + +MetricCLIPtr createCLI() { + return std::make_shared(); +} + +} // namespace Pipeline::Metrics::AbetaLoad diff --git a/localizer/src/metrics/abetaload/AbetaLoadCLI.h b/localizer/src/metrics/abetaload/AbetaLoadCLI.h new file mode 100644 index 0000000..079783d --- /dev/null +++ b/localizer/src/metrics/abetaload/AbetaLoadCLI.h @@ -0,0 +1,9 @@ +#pragma once + +#include "../shared/IMetricCLI.h" + +namespace Pipeline::Metrics::AbetaLoad { + +MetricCLIPtr createCLI(); + +} // namespace Pipeline::Metrics::AbetaLoad diff --git a/localizer/src/metrics/abetaload/AbetaLoadCalculator.cpp b/localizer/src/metrics/abetaload/AbetaLoadCalculator.cpp new file mode 100644 index 0000000..e4125d2 --- /dev/null +++ b/localizer/src/metrics/abetaload/AbetaLoadCalculator.cpp @@ -0,0 +1,135 @@ +#include "AbetaLoadCalculator.h" +#include "../../core/common/Common.h" +#include +#include +#include +#include + +AbetaLoadCalculator::AbetaLoadCalculator(ConfigurationPtr config) + : config_(std::move(config)) {} + +AbetaLoadCalculator::TemplatePaths +AbetaLoadCalculator::getTemplatePaths() const { + TemplatePaths paths; + paths.nsPath = config_->getTemplatePath("abeta_ns"); + paths.kPath = config_->getTemplatePath("abeta_k"); + return paths; +} + +Pipeline::Metrics::MetricResult +AbetaLoadCalculator::calculate(ImageType::Pointer spatialNormalizedImage) { + if (!spatialNormalizedImage) { + throw std::invalid_argument( + "AbetaLoadCalculator requires a spatially normalized image."); + } + + const auto templates = getTemplatePaths(); + // Intensity normalization using whole_cerebral reference + ImageType::Pointer refMask = + Common::nifti::loadImage(config_->getMaskPath("whole_cerebral")); + if (!refMask) { + throw std::runtime_error( + "Failed to load reference mask for AbetaLoad normalization."); + } + ImageType::Pointer imageInRefSpace = + Common::image::resampleToMatch(refMask, spatialNormalizedImage); + double refMean = Common::image::calculateMeanInMask(imageInRefSpace, refMask); + if (refMean <= 0.0) { + throw std::runtime_error( + "Reference mean is non-positive for AbetaLoad normalization."); + } + ImageType::Pointer nsTemplate = Common::nifti::loadImage(templates.nsPath); + ImageType::Pointer kTemplate = Common::nifti::loadImage(templates.kPath); + + if (!nsTemplate || !kTemplate) { + throw std::runtime_error( + "Failed to load NS/K templates for AbetaLoad calculation."); + } + + // Align templates to the subject space + ImageType::Pointer nsResampled = + Common::image::resampleToMatch(spatialNormalizedImage, nsTemplate); + ImageType::Pointer kResampled = + Common::image::resampleToMatch(spatialNormalizedImage, kTemplate); + + std::vector targetData; + std::vector nsData; + std::vector kData; + Common::image::extractImageData(spatialNormalizedImage, targetData); + Common::image::extractImageData(nsResampled, nsData); + Common::image::extractImageData(kResampled, kData); + + for (auto &v : targetData) { + v = static_cast(v / refMean); + } + + if (nsData.size() != kData.size() || targetData.size() != nsData.size()) { + throw std::runtime_error( + "AbetaLoad inputs have mismatched dimensions after resampling."); + } + + const double epsilon = 1e-8; + double sumNN = 0.0; + double sumKK = 0.0; + double sumNK = 0.0; + double sumNy = 0.0; + double sumKy = 0.0; + std::size_t validCount = 0; + + for (std::size_t i = 0; i < targetData.size(); ++i) { + const double n = static_cast(nsData[i]); + const double k = static_cast(kData[i]); + const double y = static_cast(targetData[i]); + + if (!std::isfinite(n) || !std::isfinite(k) || !std::isfinite(y)) { + continue; + } + if (std::abs(n) < epsilon && std::abs(k) < epsilon) { + continue; + } + + sumNN += n * n; + sumKK += k * k; + sumNK += n * k; + sumNy += n * y; + sumKy += k * y; + ++validCount; + } + + if (validCount == 0) { + throw std::runtime_error( + "No valid voxels found for AbetaLoad decomposition."); + } + + // Solve for coefficients (nsCoeff, kCoeff) in a two-component non-negative LS + // sense: minimize || y - nsCoeff*N - kCoeff*K ||^2. The normal equations give + // a 2x2 system: [sumNN sumNK; sumNK sumKK] * [nsCoeff kCoeff]^T = [sumNy + // sumKy]^T. We solve the system when well-conditioned; otherwise fall back to + // single-channel ratios. + const double denom = sumNN * sumKK - sumNK * sumNK; + double nsCoeff = 0.0; + double kCoeff = 0.0; + + if (std::abs(denom) > epsilon) { + nsCoeff = (sumNy * sumKK - sumKy * sumNK) / denom; + kCoeff = (sumKy * sumNN - sumNy * sumNK) / denom; + } else { + if (sumNN > epsilon) { + nsCoeff = sumNy / sumNN; + } + if (sumKK > epsilon) { + kCoeff = sumKy / sumKK; + } + } + + // Coefficients are expected to be non-negative + nsCoeff = std::max(0.0, nsCoeff); + kCoeff = std::max(0.0, kCoeff); + + Pipeline::Metrics::MetricResult result; + result.metricName = "AbetaLoad"; + result.suvr = kCoeff; + result.tracerValues["NS"] = static_cast(nsCoeff); + result.tracerValues["Abeta_load"] = static_cast(kCoeff); + return result; +} diff --git a/localizer/src/metrics/abetaload/AbetaLoadCalculator.h b/localizer/src/metrics/abetaload/AbetaLoadCalculator.h new file mode 100644 index 0000000..ebdff67 --- /dev/null +++ b/localizer/src/metrics/abetaload/AbetaLoadCalculator.h @@ -0,0 +1,23 @@ +#pragma once + +#include "../../core/common/ImageTypes.h" +#include "../../core/interfaces/IConfiguration.h" +#include "../shared/MetricTypes.h" + +class AbetaLoadCalculator { +public: + explicit AbetaLoadCalculator(ConfigurationPtr config); + ~AbetaLoadCalculator() = default; + + Pipeline::Metrics::MetricResult calculate(ImageType::Pointer spatialNormalizedImage); + +private: + ConfigurationPtr config_; + + struct TemplatePaths { + std::string nsPath; + std::string kPath; + }; + + TemplatePaths getTemplatePaths() const; +}; diff --git a/localizer/src/metrics/abetaload/AbetaLoadModule.cpp b/localizer/src/metrics/abetaload/AbetaLoadModule.cpp new file mode 100644 index 0000000..27165e1 --- /dev/null +++ b/localizer/src/metrics/abetaload/AbetaLoadModule.cpp @@ -0,0 +1,11 @@ +#include "AbetaLoadModule.h" + +#include "AbetaLoadCLI.h" + +namespace Pipeline::Metrics::AbetaLoad { + +void registerModule(MetricRegistry& registry) { + registry.registerModule({"abetaload", true, &createCLI}); +} + +} // namespace Pipeline::Metrics::AbetaLoad diff --git a/localizer/src/metrics/abetaload/AbetaLoadModule.h b/localizer/src/metrics/abetaload/AbetaLoadModule.h new file mode 100644 index 0000000..66343f5 --- /dev/null +++ b/localizer/src/metrics/abetaload/AbetaLoadModule.h @@ -0,0 +1,9 @@ +#pragma once + +#include "../shared/MetricRegistry.h" + +namespace Pipeline::Metrics::AbetaLoad { + +void registerModule(MetricRegistry& registry); + +} // namespace Pipeline::Metrics::AbetaLoad diff --git a/localizer/src/metrics/abetaload/AbetaLoadService.cpp b/localizer/src/metrics/abetaload/AbetaLoadService.cpp new file mode 100644 index 0000000..a143900 --- /dev/null +++ b/localizer/src/metrics/abetaload/AbetaLoadService.cpp @@ -0,0 +1,147 @@ +#include "AbetaLoadService.h" + +#include "../../core/common/Filesystem.h" +#include "../../core/common/NormalizationContracts.h" +#include "../../metrics/shared/BatchRunner.h" +#include "../../metrics/shared/DebugPathHelpers.h" +#include "../../metrics/shared/MetricRunResult.h" +#include "../../metrics/shared/SingleRunner.h" +#include "AbetaLoadCalculator.h" +#include +#include +#include + +namespace Pipeline::Metrics::AbetaLoad { + +namespace { + +constexpr const char* kLogTag = "abetaload"; +constexpr const char* kBatchOutputSuffix = "_abetaload.nii"; + +SpatialNormalizationRequest buildNormalizationRequest(const AbetaLoadCLIOptions& options, + const std::string& inputPath, + const std::string& debugBasePath) { + SpatialNormalizationRequest request; + request.inputPath = inputPath; + request.skip = options.skipRegistration; + request.options.useIterativeRigid = options.useIterativeRigid; + request.options.useManualFOV = options.useManualFOV; + request.options.enableDebugOutput = options.enableDebugOutput; + request.options.debugOutputBasePath = options.enableDebugOutput ? debugBasePath : std::string{}; + return request; +} + +} // namespace + +AbetaLoadService::AbetaLoadService(ConfigurationPtr config, + std::shared_ptr spatialService, + std::shared_ptr fileService) + : config_(std::move(config)), + spatialService_(std::move(spatialService)), + fileService_(std::move(fileService)) { + if (!config_ || !spatialService_ || !fileService_) { + throw std::invalid_argument("AbetaLoadService requires configuration, normalization, and file services"); + } +} + +int AbetaLoadService::run(AbetaLoadCLIOptions options, const std::string& fullCommand) { + Pipeline::Metrics::Shared::configureDerivedDebugBasePath(options); + if (options.batchMode || !options.bidsPattern.empty()) { + return runBatch(options, fullCommand); + } + return runSingle(options, fullCommand); +} + +Metrics::MetricResult AbetaLoadService::executeForImage(const AbetaLoadCLIOptions& options, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugBasePath) const { + auto normalizationOutput = + spatialService_->normalize(buildNormalizationRequest(options, inputPath, debugBasePath)); + fileService_->saveNormalizedImage({normalizationOutput.spatiallyNormalizedImage, outputPath}); + + AbetaLoadCalculator calculator(config_); + return calculator.calculate(normalizationOutput.spatiallyNormalizedImage); +} + +int AbetaLoadService::runSingle(const AbetaLoadCLIOptions& options, + const std::string& fullCommand) const { + if (!Common::fs::ensureParentDirectory(options.outputPath)) { + std::cerr << "[" << kLogTag << "] Failed to prepare output directory: " + << options.outputPath << std::endl; + return EXIT_FAILURE; + } + + Pipeline::Metrics::Shared::SingleRunnerHooks hooks; + hooks.logTag = kLogTag; + hooks.resolveDebugBase = [](const AbetaLoadCLIOptions& runnerOptions, const std::string& outputPath) { + return runnerOptions.enableDebugOutput + ? Common::path::deriveDebugBasePath(outputPath) + : std::string{}; + }; + hooks.execute = [this](const AbetaLoadCLIOptions& runnerOptions, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugBase) { + Pipeline::Metrics::Shared::MetricRunResult result; + result.metricResults.push_back( + executeForImage(runnerOptions, inputPath, outputPath, debugBase)); + return result; + }; + hooks.logResults = [](const AbetaLoadCLIOptions& runnerOptions, + const Pipeline::Metrics::Shared::MetricRunResult& result) { + std::cout << "\n[" << kLogTag + << "] Spatial normalization complete. Normalized image saved to " + << runnerOptions.outputPath << std::endl; + for (const auto& metric : result.metricResults) { + logMetricResult(metric); + } + }; + return Pipeline::Metrics::Shared::runSingle(options, fullCommand, hooks); +} + +int AbetaLoadService::runBatch(const AbetaLoadCLIOptions& options, + const std::string& fullCommand) const { + Pipeline::Metrics::Shared::BatchRunnerHooks hooks; + hooks.logTag = kLogTag; + hooks.batchOutputSuffix = kBatchOutputSuffix; + hooks.resolveDebugBase = [](const AbetaLoadCLIOptions& runnerOptions, const std::string& outputPath) { + return runnerOptions.enableDebugOutput + ? Common::path::deriveDebugBasePath(outputPath) + : std::string{}; + }; + hooks.execute = [this](const AbetaLoadCLIOptions& runnerOptions, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugBase) { + Pipeline::Metrics::Shared::MetricRunResult result; + result.metricResults.push_back( + executeForImage(runnerOptions, inputPath, outputPath, debugBase)); + return result; + }; + hooks.logResults = [](const AbetaLoadCLIOptions&, + const Pipeline::Metrics::Shared::MetricRunResult& result) { + for (const auto& metric : result.metricResults) { + logMetricResult(metric); + } + }; + return Pipeline::Metrics::Shared::runBatch(options, fullCommand, hooks); +} + +void AbetaLoadService::logMetricResult(const Metrics::MetricResult& result) { + std::cout << "\n=== AbetaLoad Results ===" << std::endl; + std::cout << "Metric: " << result.metricName << std::endl; + for (const auto& [label, value] : result.tracerValues) { + std::cout << " " << label << ": " << value << std::endl; + } + std::cout << " Abeta_load (SUVr field): " << result.suvr << std::endl; +} + +std::shared_ptr createService(ServiceContainer& container) { + auto config = container.resolve(); + auto spatialService = container.resolve(); + auto fileService = container.resolve(); + return std::make_shared(config, spatialService, fileService); +} + +} // namespace Pipeline::Metrics::AbetaLoad diff --git a/localizer/src/metrics/abetaload/AbetaLoadService.h b/localizer/src/metrics/abetaload/AbetaLoadService.h new file mode 100644 index 0000000..1b074d2 --- /dev/null +++ b/localizer/src/metrics/abetaload/AbetaLoadService.h @@ -0,0 +1,50 @@ +#pragma once + +#include "../../core/di/ServiceContainer.h" +#include "../../core/interfaces/IConfiguration.h" +#include "../../core/services/IFileService.h" +#include "../../core/services/ISpatialNormalizationService.h" +#include "../shared/MetricTypes.h" +#include +#include + +namespace Pipeline::Metrics::AbetaLoad { + +struct AbetaLoadCLIOptions { + std::string inputPath; + std::string outputPath; + std::string configPath = "config.toml"; + bool skipRegistration = false; + bool useIterativeRigid = false; + bool useManualFOV = false; + bool enableDebugOutput = false; + bool batchMode = false; + std::string bidsPattern; + std::string debugOutputBasePath; +}; + +class AbetaLoadService { +public: + AbetaLoadService(ConfigurationPtr config, + std::shared_ptr spatialService, + std::shared_ptr fileService); + + int run(AbetaLoadCLIOptions options, const std::string& fullCommand); + +private: + Metrics::MetricResult executeForImage(const AbetaLoadCLIOptions& options, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugBasePath) const; + int runSingle(const AbetaLoadCLIOptions& options, const std::string& fullCommand) const; + int runBatch(const AbetaLoadCLIOptions& options, const std::string& fullCommand) const; + static void logMetricResult(const Metrics::MetricResult& result); + + ConfigurationPtr config_; + std::shared_ptr spatialService_; + std::shared_ptr fileService_; +}; + +std::shared_ptr createService(ServiceContainer& container); + +} // namespace Pipeline::Metrics::AbetaLoad diff --git a/localizer/src/metrics/adad/ADADCLI.cpp b/localizer/src/metrics/adad/ADADCLI.cpp new file mode 100644 index 0000000..7bec6ee --- /dev/null +++ b/localizer/src/metrics/adad/ADADCLI.cpp @@ -0,0 +1,89 @@ +#include "ADADCLI.h" + +#include "../../core/di/Bootstrap.h" +#include "ADADService.h" +#include +#include + +namespace Pipeline::Metrics::ADAD { + +namespace { + +void addBaseArguments(argparse::ArgumentParser& parser) { + parser.add_argument("--input") + .help("Input PET image path") + .required(); + parser.add_argument("--output") + .help("Output processed image path") + .required(); + parser.add_argument("--config") + .help("Configuration file path") + .default_value(std::string{"config.toml"}); + parser.add_argument("--debug") + .help("Enable debug mode") + .default_value(false) + .implicit_value(true); +} + +void addSpatialNormalizationArguments(argparse::ArgumentParser& parser) { + parser.add_argument("-i", "--iterative") + .help("Use iterative rigid transformation") + .default_value(false) + .implicit_value(true); + parser.add_argument("-m", "--manual-fov") + .help("Use manual FOV placement") + .default_value(false) + .implicit_value(true); +} + +class ADADCLI : public IMetricCLI { +public: + std::string getSubcommandName() const override { + return "adad"; + } + + std::string getDescription() const override { + return "Prototype ADAD scoring pipeline built with service/DI refactor"; + } + + void configureArguments(argparse::ArgumentParser& parser) override { + addBaseArguments(parser); + addSpatialNormalizationArguments(parser); + parser.add_argument("--skip-normalization") + .help("Skip the spatial normalization stage") + .default_value(false) + .implicit_value(true); + parser.add_argument("--modality") + .help("Decoupling modality to use (abeta or tau)") + .default_value(std::string("abeta")) + .choices("abeta", "tau"); + } + + int execute(const argparse::ArgumentParser& parser, const std::string& fullCommand) override { + ADADCLIOptions options; + options.inputPath = parser.get("--input"); + options.outputPath = parser.get("--output"); + options.configPath = parser.get("--config"); + options.enableDebugOutput = parser.get("--debug"); + options.skipRegistration = parser.get("--skip-normalization"); + options.useIterativeRigid = parser.get("--iterative"); + options.useManualFOV = parser.get("--manual-fov"); + options.modality = parser.get("--modality"); + + BootstrapOptions bootstrapOptions; + bootstrapOptions.configPath = options.configPath; + bootstrapOptions.enableConfigDebug = options.enableDebugOutput; + bootstrapOptions.logTag = "adad"; + auto container = buildCoreContainer(bootstrapOptions); + auto service = createService(*container); + return service->run(options, fullCommand); + } +}; + +} // namespace + +MetricCLIPtr createCLI() { + return std::make_shared(); +} + +} // namespace Pipeline::Metrics::ADAD diff --git a/localizer/src/metrics/adad/ADADCLI.h b/localizer/src/metrics/adad/ADADCLI.h new file mode 100644 index 0000000..c87a78b --- /dev/null +++ b/localizer/src/metrics/adad/ADADCLI.h @@ -0,0 +1,10 @@ +#pragma once + +#include "../shared/IMetricCLI.h" + +namespace Pipeline::Metrics::ADAD { + +MetricCLIPtr createCLI(); + +} // namespace Pipeline::Metrics::ADAD + diff --git a/localizer/src/metrics/adad/ADADModule.cpp b/localizer/src/metrics/adad/ADADModule.cpp new file mode 100644 index 0000000..7b18039 --- /dev/null +++ b/localizer/src/metrics/adad/ADADModule.cpp @@ -0,0 +1,11 @@ +#include "ADADModule.h" + +#include "ADADCLI.h" + +namespace Pipeline::Metrics::ADAD { + +void registerModule(MetricRegistry& registry) { + registry.registerModule({"adad", true, &createCLI}); +} + +} // namespace Pipeline::Metrics::ADAD diff --git a/localizer/src/metrics/adad/ADADModule.h b/localizer/src/metrics/adad/ADADModule.h new file mode 100644 index 0000000..10fce9c --- /dev/null +++ b/localizer/src/metrics/adad/ADADModule.h @@ -0,0 +1,9 @@ +#pragma once + +#include "../shared/MetricRegistry.h" + +namespace Pipeline::Metrics::ADAD { + +void registerModule(MetricRegistry& registry); + +} // namespace Pipeline::Metrics::ADAD diff --git a/localizer/src/metrics/adad/ADADService.cpp b/localizer/src/metrics/adad/ADADService.cpp new file mode 100644 index 0000000..7c13d92 --- /dev/null +++ b/localizer/src/metrics/adad/ADADService.cpp @@ -0,0 +1,192 @@ +#include "ADADService.h" + +#include "../../core/common/Filesystem.h" +#include "../../core/common/NormalizationContracts.h" +#include "../../metrics/shared/DebugPathHelpers.h" +#include "../../metrics/shared/MetricRunResult.h" +#include "../../metrics/shared/SingleRunner.h" +#include "Decoupler.h" +#include +#include +#include +#include + +namespace Pipeline::Metrics::ADAD { + +namespace { + +constexpr const char* kLogTag = "adad"; + +SpatialNormalizationRequest buildNormalizationRequest(const ADADCLIOptions& options, + const std::string& inputPath, + const std::string& debugBasePath) { + SpatialNormalizationRequest request; + request.inputPath = inputPath; + request.skip = options.skipRegistration; + request.options.useIterativeRigid = options.useIterativeRigid; + request.options.useManualFOV = options.useManualFOV; + request.options.enableDebugOutput = options.enableDebugOutput; + request.options.debugOutputBasePath = options.enableDebugOutput ? debugBasePath : std::string{}; + request.options.enableAdniPetCore = true; + return request; +} + +} // namespace + +ADADService::ADADService(ConfigurationPtr config, + std::shared_ptr spatialService, + std::shared_ptr fileService) + : config_(std::move(config)), + spatialService_(std::move(spatialService)), + fileService_(std::move(fileService)) { + if (!config_ || !spatialService_ || !fileService_) { + throw std::invalid_argument("ADADService requires configuration, normalization, and file services"); + } +} + +int ADADService::run(ADADCLIOptions options, const std::string& fullCommand) { + if (options.batchMode) { + std::cerr << "[adad] Batch mode is not supported." << std::endl; + return EXIT_FAILURE; + } + + Pipeline::Metrics::Shared::configureDerivedDebugBasePath(options); + return runSingle(options, fullCommand); +} + +Metrics::MetricResult ADADService::executeForImage(const ADADCLIOptions& options, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugBasePath) const { + auto normalizationOutput = + spatialService_->normalize(buildNormalizationRequest(options, inputPath, debugBasePath)); + fileService_->saveNormalizedImage({normalizationOutput.spatiallyNormalizedImage, outputPath}); + + const std::string modality = normalizeModality(options.modality); + const std::string key = modality == "tau" ? "tau_decoupler" : "abeta_decoupler"; + DecoupledResult decoupled; + auto modelPaths = config_->getModelPaths(key); + if (!modelPaths.empty()) { + Decoupler decoupler(modelPaths); + decoupled = decoupler.predict(normalizationOutput.spatiallyNormalizedImage); + } else { + Decoupler decoupler(config_->getModelPath(key)); + decoupled = decoupler.predict(normalizationOutput.spatiallyNormalizedImage); + } + Common::fs::ensureParentDirectory(outputPath); + decoupled.SaveResults(outputPath); + + Metrics::MetricResult metric; + metric.metricName = modality == "tau" ? "ADAD_tau" : "ADAD_abeta"; + metric.suvr = decoupled.ADADscore; + metric.tracerValues = buildTracerValues(modality, decoupled); + return metric; +} + +int ADADService::runSingle(const ADADCLIOptions& options, const std::string& fullCommand) const { + if (!Common::fs::ensureParentDirectory(options.outputPath)) { + std::cerr << "[" << kLogTag << "] Failed to prepare output directory: " + << options.outputPath << std::endl; + return EXIT_FAILURE; + } + + Pipeline::Metrics::Shared::SingleRunnerHooks hooks; + hooks.logTag = kLogTag; + hooks.resolveDebugBase = [](const ADADCLIOptions& runnerOptions, const std::string& outputPath) { + return runnerOptions.enableDebugOutput + ? Common::path::deriveDebugBasePath(outputPath) + : std::string{}; + }; + hooks.execute = [this](const ADADCLIOptions& runnerOptions, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugBase) { + Pipeline::Metrics::Shared::MetricRunResult result; + result.metricResults.push_back( + executeForImage(runnerOptions, inputPath, outputPath, debugBase)); + return result; + }; + hooks.logResults = [](const ADADCLIOptions& runnerOptions, + const Pipeline::Metrics::Shared::MetricRunResult& result) { + std::cout << "\n[" << kLogTag + << "] Spatial normalization complete. Normalized image saved to " + << runnerOptions.outputPath << std::endl; + for (const auto& metric : result.metricResults) { + logMetricResult(metric, normalizeModality(runnerOptions.modality)); + } + }; + return Pipeline::Metrics::Shared::runSingle(options, fullCommand, hooks); +} + +std::map ADADService::buildTracerValues(const std::string& modality, + const DecoupledResult& decoupled) const { + auto coeffs = loadTracerCoefficients(modality); + std::map values; + if (coeffs.empty()) { + values["ADAD_score"] = static_cast(decoupled.ADADscore); + } else { + for (const auto& [tracer, pair] : coeffs) { + values[tracer] = pair.first * static_cast(decoupled.ADADscore) + pair.second; + } + } + values["AD_probability"] = decoupled.ADprob * 100.0f; + return values; +} + +std::map> ADADService::loadTracerCoefficients( + const std::string& modality) const { + std::map> coeffs; + const std::string sectionName = "adad_" + modality + ".tracers"; + try { + auto section = config_->getSection(sectionName); + for (const auto& [key, value] : section) { + auto dot = key.find('.'); + if (dot == std::string::npos) { + continue; + } + std::string tracer = key.substr(0, dot); + std::string entry = key.substr(dot + 1); + float parsedValue = 0.0f; + try { + parsedValue = std::stof(value); + } catch (...) { + continue; + } + auto& pair = coeffs[tracer]; + if (entry == "slope") { + pair.first = parsedValue; + } else if (entry == "intercept") { + pair.second = parsedValue; + } + } + } catch (...) { + coeffs.clear(); + } + return coeffs; +} + +std::string ADADService::normalizeModality(const std::string& raw) { + std::string modality = Common::path::toLower(raw); + if (modality != "tau") { + modality = "abeta"; + } + return modality; +} + +void ADADService::logMetricResult(const Metrics::MetricResult& result, const std::string& modality) { + std::cout << "\n=== Refactor ADAD Results (" << modality << ") ===" << std::endl; + std::cout << "Metric: " << result.metricName << std::endl; + std::cout << "ADAD score: " << result.suvr << std::endl; + for (const auto& [label, value] : result.tracerValues) { + std::cout << " " << label << ": " << value << std::endl; + } +} + +std::shared_ptr createService(ServiceContainer& container) { + auto config = container.resolve(); + auto spatialService = container.resolve(); + auto fileService = container.resolve(); + return std::make_shared(config, spatialService, fileService); +} + +} // namespace Pipeline::Metrics::ADAD diff --git a/localizer/src/metrics/adad/ADADService.h b/localizer/src/metrics/adad/ADADService.h new file mode 100644 index 0000000..d4575ce --- /dev/null +++ b/localizer/src/metrics/adad/ADADService.h @@ -0,0 +1,55 @@ +#pragma once + +#include "../../core/di/ServiceContainer.h" +#include "../../core/interfaces/IConfiguration.h" +#include "../../core/services/IFileService.h" +#include "../../core/services/ISpatialNormalizationService.h" +#include "../shared/MetricTypes.h" +#include "Decoupler.h" +#include +#include +#include + +namespace Pipeline::Metrics::ADAD { + +struct ADADCLIOptions { + std::string inputPath; + std::string outputPath; + std::string configPath = "config.toml"; + bool enableDebugOutput = false; + std::string debugOutputBasePath; + bool batchMode = false; + bool skipRegistration = false; + bool useIterativeRigid = false; + bool useManualFOV = false; + std::string modality = "abeta"; +}; + +class ADADService { +public: + ADADService(ConfigurationPtr config, + std::shared_ptr spatialService, + std::shared_ptr fileService); + + int run(ADADCLIOptions options, const std::string& fullCommand); + +private: + Metrics::MetricResult executeForImage(const ADADCLIOptions& options, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugBasePath) const; + int runSingle(const ADADCLIOptions& options, const std::string& fullCommand) const; + std::map buildTracerValues(const std::string& modality, + const DecoupledResult& decoupled) const; + std::map> loadTracerCoefficients(const std::string& modality) const; + static std::string normalizeModality(const std::string& raw); + static void logMetricResult(const Metrics::MetricResult& result, const std::string& modality); + + ConfigurationPtr config_; + std::shared_ptr spatialService_; + std::shared_ptr fileService_; +}; + +std::shared_ptr createService(ServiceContainer& container); + +} // namespace Pipeline::Metrics::ADAD diff --git a/localizer/src/decouplers/Decoupler.cpp b/localizer/src/metrics/adad/Decoupler.cpp similarity index 86% rename from localizer/src/decouplers/Decoupler.cpp rename to localizer/src/metrics/adad/Decoupler.cpp index 6b98ffe..aed9ed2 100644 --- a/localizer/src/decouplers/Decoupler.cpp +++ b/localizer/src/metrics/adad/Decoupler.cpp @@ -1,13 +1,15 @@ #include "Decoupler.h" -#include "../utils/onnx_path_utils.h" +#include "../../core/common/Common.h" +#include "../../core/common/OnnxPath.h" void DecoupledResult::SaveResults(const std::string& fpath) { - Common::SaveImage(strippedImage, - Common::addSuffixToFilePath(fpath, "_stripped_image")); - Common::SaveImage(strippedComponent, Common::addSuffixToFilePath( - fpath, "_stripped_component")); - Common::SaveImage(ADprobMap, - Common::addSuffixToFilePath(fpath, "_AD_prob_map")); + Common::nifti::saveImage( + strippedImage, Common::path::addSuffix(fpath, "_stripped_image")); + Common::nifti::saveImage( + strippedComponent, + Common::path::addSuffix(fpath, "_stripped_component")); + Common::nifti::saveImage( + ADprobMap, Common::path::addSuffix(fpath, "_AD_prob_map")); } void DecoupledResult::printResult() const { @@ -27,7 +29,7 @@ Decoupler::Decoupler(const std::string& modelPath) : env(ORT_LOGGING_LEVEL_WARNING, "Decouple"), sessions() { Ort::SessionOptions sessionOptions; sessionOptions.SetIntraOpNumThreads(1); - auto ortModelPath = OrtUtils::MakeOrtPath(modelPath); + auto ortModelPath = Common::onnx::makeOrtPath(modelPath); try { this->sessions.push_back( new Ort::Session(this->env, ortModelPath.c_str(), sessionOptions)); @@ -42,7 +44,7 @@ Decoupler::Decoupler(const std::vector& modelPaths) sessionOptions.SetIntraOpNumThreads(1); try { for (const auto& p : modelPaths) { - auto ortModelPath = OrtUtils::MakeOrtPath(p); + auto ortModelPath = Common::onnx::makeOrtPath(p); this->sessions.push_back( new Ort::Session(this->env, ortModelPath.c_str(), sessionOptions)); } @@ -97,7 +99,7 @@ std::unordered_map> Decoupler::_predict_one( DecoupledResult Decoupler::predict(ImageType::Pointer inputImage) { // Convert input image to vector std::vector inputTensor; - Common::ExtractImageData(inputImage, inputTensor); + Common::image::extractImageData(inputImage, inputTensor); // Run inference (support ensemble) and aggregate std::unordered_map> aggregated; @@ -123,21 +125,21 @@ DecoupledResult Decoupler::predict(ImageType::Pointer inputImage) { auto size = inputImage->GetLargestPossibleRegion().GetSize(); for (auto& [name, imgData] : aggregated) { if (name == "stripped_AD_images_cal") { - ImageType::Pointer image = Common::CreateImageFromVector(imgData, size); + ImageType::Pointer image = Common::image::createImageFromVector(imgData, size); image->SetOrigin(inputImage->GetOrigin()); image->SetSpacing(inputImage->GetSpacing()); image->SetDirection(inputImage->GetDirection()); decoupledResult.strippedImage = image; } else if (name == "stripped_component_cal") { ImageType::Pointer image = - Common::CreateImageFromVector(imgData, {160, 160, 96}); + Common::image::createImageFromVector(imgData, {160, 160, 96}); image->SetOrigin(inputImage->GetOrigin()); image->SetSpacing(inputImage->GetSpacing()); image->SetDirection(inputImage->GetDirection()); decoupledResult.strippedComponent = image; } else if (name == "AD_prob_map_cal") { ImageType::Pointer image = - Common::CreateImageFromVector(imgData, {160, 160, 96}); + Common::image::createImageFromVector(imgData, {160, 160, 96}); decoupledResult.ADprobMap = image; image->SetOrigin(inputImage->GetOrigin()); image->SetSpacing(inputImage->GetSpacing()); diff --git a/localizer/src/decouplers/Decoupler.h b/localizer/src/metrics/adad/Decoupler.h similarity index 95% rename from localizer/src/decouplers/Decoupler.h rename to localizer/src/metrics/adad/Decoupler.h index 88b41a0..8223a82 100644 --- a/localizer/src/decouplers/Decoupler.h +++ b/localizer/src/metrics/adad/Decoupler.h @@ -4,7 +4,7 @@ #include #include -#include "../utils/common.h" +#include "../../core/common/ImageTypes.h" #include "onnxruntime_cxx_api.h" class DecoupledResult { diff --git a/localizer/src/metrics/centaur/CenTauRCLI.cpp b/localizer/src/metrics/centaur/CenTauRCLI.cpp new file mode 100644 index 0000000..189dab8 --- /dev/null +++ b/localizer/src/metrics/centaur/CenTauRCLI.cpp @@ -0,0 +1,103 @@ +#include "CenTauRCLI.h" + +#include "../../core/di/Bootstrap.h" +#include "CenTauRService.h" +#include +#include + +namespace Pipeline::Metrics::Centaur { + +namespace { + +void addBaseArguments(argparse::ArgumentParser& parser) { + parser.add_argument("--input") + .help("Input PET image path") + .required(); + parser.add_argument("--output") + .help("Output processed image path") + .required(); + parser.add_argument("--config") + .help("Configuration file path") + .default_value(std::string{"config.toml"}); + parser.add_argument("--debug") + .help("Enable debug mode") + .default_value(false) + .implicit_value(true); + parser.add_argument("--batch") + .help("Enable batch processing mode") + .default_value(false) + .implicit_value(true); + parser.add_argument("--bids") + .help("Treat --input as a PET-BIDS dataset root and process PET files matching this regex") + .default_value(std::string{}); +} + +void addSpatialNormalizationArguments(argparse::ArgumentParser& parser) { + parser.add_argument("-i", "--iterative") + .help("Use iterative rigid transformation") + .default_value(false) + .implicit_value(true); + parser.add_argument("-m", "--manual-fov") + .help("Use manual FOV placement") + .default_value(false) + .implicit_value(true); +} + +void addSUVrDerivedArguments(argparse::ArgumentParser& parser) { + addBaseArguments(parser); + addSpatialNormalizationArguments(parser); + parser.add_argument("--suvr") + .help("Include SUVr values in the output") + .default_value(false) + .implicit_value(true); + parser.add_argument("--skip-normalization") + .help("Skip spatial normalization and calculate metrics directly") + .default_value(false) + .implicit_value(true); +} + +class CenTauRCLI : public IMetricCLI { +public: + std::string getSubcommandName() const override { + return "centaur"; + } + + std::string getDescription() const override { + return "Prototype CenTauR pipeline built with service/DI refactor"; + } + + void configureArguments(argparse::ArgumentParser& parser) override { + addSUVrDerivedArguments(parser); + } + + int execute(const argparse::ArgumentParser& parser, const std::string& fullCommand) override { + CenTauRCLIOptions options; + options.inputPath = parser.get("--input"); + options.outputPath = parser.get("--output"); + options.configPath = parser.get("--config"); + options.includeSUVr = parser.get("--suvr"); + options.skipRegistration = parser.get("--skip-normalization"); + options.useIterativeRigid = parser.get("--iterative"); + options.useManualFOV = parser.get("--manual-fov"); + options.enableDebugOutput = parser.get("--debug"); + options.batchMode = parser.get("--batch"); + options.bidsPattern = parser.get("--bids"); + + BootstrapOptions bootstrapOptions; + bootstrapOptions.configPath = options.configPath; + bootstrapOptions.enableConfigDebug = options.enableDebugOutput; + bootstrapOptions.logTag = "centaur"; + auto container = buildCoreContainer(bootstrapOptions); + auto service = createService(*container); + return service->run(options, fullCommand); + } +}; + +} // namespace + +MetricCLIPtr createCLI() { + return std::make_shared(); +} + +} // namespace Pipeline::Metrics::Centaur + diff --git a/localizer/src/metrics/centaur/CenTauRCLI.h b/localizer/src/metrics/centaur/CenTauRCLI.h new file mode 100644 index 0000000..de07636 --- /dev/null +++ b/localizer/src/metrics/centaur/CenTauRCLI.h @@ -0,0 +1,10 @@ +#pragma once + +#include "../shared/IMetricCLI.h" + +namespace Pipeline::Metrics::Centaur { + +MetricCLIPtr createCLI(); + +} // namespace Pipeline::Metrics::Centaur + diff --git a/localizer/src/metrics/centaur/CenTauRCalculator.cpp b/localizer/src/metrics/centaur/CenTauRCalculator.cpp new file mode 100644 index 0000000..b69d282 --- /dev/null +++ b/localizer/src/metrics/centaur/CenTauRCalculator.cpp @@ -0,0 +1,27 @@ +#include "CenTauRCalculator.h" + +#include "../suvr/SUVrCalculator.h" +#include +#include + +Pipeline::Metrics::MetricResult CenTauRCalculator::calculate(const Input& input) const { + if (!input.spatiallyNormalizedImage) { + throw std::invalid_argument("CenTauRCalculator requires a spatially normalized image"); + } + if (input.voiMaskPath.empty() || input.refMaskPath.empty()) { + throw std::invalid_argument("CenTauRCalculator requires VOI and reference masks"); + } + + const double suvr = SUVrCalculator::calculateSUVr( + input.spatiallyNormalizedImage, input.voiMaskPath, input.refMaskPath); + + Pipeline::Metrics::MetricResult result; + result.metricName = "CenTauR"; + result.suvr = suvr; + + for (const auto& [tracerName, params] : input.tracerParameters) { + float centaur = (suvr - params.baselineSuvr) / (params.maxSuvr - params.baselineSuvr) * 100; + result.tracerValues[tracerName] = centaur; + } + return result; +} diff --git a/localizer/src/metrics/centaur/CenTauRCalculator.h b/localizer/src/metrics/centaur/CenTauRCalculator.h new file mode 100644 index 0000000..8137ec5 --- /dev/null +++ b/localizer/src/metrics/centaur/CenTauRCalculator.h @@ -0,0 +1,24 @@ +#pragma once + +#include "../../core/common/ImageTypes.h" +#include "../shared/MetricTypes.h" +#include +#include +#include + +class CenTauRCalculator { +public: + struct TracerParams { + float baselineSuvr; + float maxSuvr; + }; + + struct Input { + ImageType::Pointer spatiallyNormalizedImage; + std::string voiMaskPath; + std::string refMaskPath; + std::map tracerParameters; + }; + + Pipeline::Metrics::MetricResult calculate(const Input& input) const; +}; diff --git a/localizer/src/metrics/centaur/CenTauRModule.cpp b/localizer/src/metrics/centaur/CenTauRModule.cpp new file mode 100644 index 0000000..9875450 --- /dev/null +++ b/localizer/src/metrics/centaur/CenTauRModule.cpp @@ -0,0 +1,11 @@ +#include "CenTauRModule.h" + +#include "CenTauRCLI.h" + +namespace Pipeline::Metrics::Centaur { + +void registerModule(MetricRegistry& registry) { + registry.registerModule({"centaur", true, &createCLI}); +} + +} // namespace Pipeline::Metrics::Centaur diff --git a/localizer/src/metrics/centaur/CenTauRModule.h b/localizer/src/metrics/centaur/CenTauRModule.h new file mode 100644 index 0000000..f9fe2bd --- /dev/null +++ b/localizer/src/metrics/centaur/CenTauRModule.h @@ -0,0 +1,9 @@ +#pragma once + +#include "../shared/MetricRegistry.h" + +namespace Pipeline::Metrics::Centaur { + +void registerModule(MetricRegistry& registry); + +} // namespace Pipeline::Metrics::Centaur diff --git a/localizer/src/metrics/centaur/CenTauRService.cpp b/localizer/src/metrics/centaur/CenTauRService.cpp new file mode 100644 index 0000000..cb4a69b --- /dev/null +++ b/localizer/src/metrics/centaur/CenTauRService.cpp @@ -0,0 +1,170 @@ +#include "CenTauRService.h" + +#include "../../core/common/Filesystem.h" +#include "../../core/common/NormalizationContracts.h" +#include "../../metrics/shared/BatchRunner.h" +#include "../../metrics/shared/DebugPathHelpers.h" +#include "../../metrics/shared/MetricRunResult.h" +#include "../../metrics/shared/SingleRunner.h" +#include "CenTauRCalculator.h" +#include +#include +#include + +namespace Pipeline::Metrics::Centaur { + +namespace { + +constexpr const char* kLogTag = "centaur"; +constexpr const char* kBatchOutputSuffix = "_centaur_refactor.nii"; + +std::map loadTracerParameters(ConfigurationPtr config) { + if (!config) { + throw std::invalid_argument("CenTauR requires configuration"); + } + + std::map params; + params["FTP"] = {config->getFloat("centaur.tracers.ftp.baseline", 1.06f), + config->getFloat("centaur.tracers.ftp.max", 2.13f)}; + params["GTP1"] = {config->getFloat("centaur.tracers.gtp1.baseline", 1.08f), + config->getFloat("centaur.tracers.gtp1.max", 1.69f)}; + params["MK6240"] = {config->getFloat("centaur.tracers.mk6240.baseline", 0.93f), + config->getFloat("centaur.tracers.mk6240.max", 3.30f)}; + params["PI2620"] = {config->getFloat("centaur.tracers.pi2620.baseline", 1.17f), + config->getFloat("centaur.tracers.pi2620.max", 2.12f)}; + params["RO948"] = {config->getFloat("centaur.tracers.ro948.baseline", 1.03f), + config->getFloat("centaur.tracers.ro948.max", 2.40f)}; + return params; +} + +SpatialNormalizationRequest buildNormalizationRequest(const CenTauRCLIOptions& options, + const std::string& inputPath, + const std::string& debugBasePath) { + SpatialNormalizationRequest request; + request.inputPath = inputPath; + request.skip = options.skipRegistration; + request.options.useIterativeRigid = options.useIterativeRigid; + request.options.useManualFOV = options.useManualFOV; + request.options.enableDebugOutput = options.enableDebugOutput; + request.options.debugOutputBasePath = options.enableDebugOutput ? debugBasePath : std::string{}; + return request; +} + +} // namespace + +CenTauRService::CenTauRService(ConfigurationPtr config, + std::shared_ptr spatialService, + std::shared_ptr fileService) + : config_(std::move(config)), + spatialService_(std::move(spatialService)), + fileService_(std::move(fileService)) { + if (!config_ || !spatialService_ || !fileService_) { + throw std::invalid_argument("CenTauRService requires configuration, normalization, and file services"); + } +} + +int CenTauRService::run(CenTauRCLIOptions options, const std::string& fullCommand) { + Pipeline::Metrics::Shared::configureDerivedDebugBasePath(options); + if (options.batchMode || !options.bidsPattern.empty()) { + return runBatch(options, fullCommand); + } + return runSingle(options, fullCommand); +} + +Metrics::MetricResult CenTauRService::executeForImage(const CenTauRCLIOptions& options, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugBasePath) const { + auto normalizationOutput = + spatialService_->normalize(buildNormalizationRequest(options, inputPath, debugBasePath)); + fileService_->saveNormalizedImage({normalizationOutput.spatiallyNormalizedImage, outputPath}); + + CenTauRCalculator::Input input; + input.spatiallyNormalizedImage = normalizationOutput.spatiallyNormalizedImage; + input.voiMaskPath = config_->getMaskPath("centaur_voi"); + input.refMaskPath = config_->getMaskPath("centaur_ref"); + input.tracerParameters = loadTracerParameters(config_); + return CenTauRCalculator().calculate(input); +} + +int CenTauRService::runSingle(const CenTauRCLIOptions& options, const std::string& fullCommand) const { + if (!Common::fs::ensureParentDirectory(options.outputPath)) { + std::cerr << "[" << kLogTag << "] Failed to prepare output directory: " + << options.outputPath << std::endl; + return EXIT_FAILURE; + } + + Pipeline::Metrics::Shared::SingleRunnerHooks hooks; + hooks.logTag = kLogTag; + hooks.resolveDebugBase = [](const CenTauRCLIOptions& runnerOptions, const std::string& outputPath) { + return runnerOptions.enableDebugOutput + ? Common::path::deriveDebugBasePath(outputPath) + : std::string{}; + }; + hooks.execute = [this](const CenTauRCLIOptions& runnerOptions, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugBase) { + Pipeline::Metrics::Shared::MetricRunResult result; + result.metricResults.push_back( + executeForImage(runnerOptions, inputPath, outputPath, debugBase)); + return result; + }; + hooks.logResults = [](const CenTauRCLIOptions& runnerOptions, + const Pipeline::Metrics::Shared::MetricRunResult& result) { + std::cout << "\n[" << kLogTag + << "] Spatial normalization complete. Normalized image saved to " + << runnerOptions.outputPath << std::endl; + for (const auto& metric : result.metricResults) { + logMetricResult(metric, runnerOptions.includeSUVr); + } + }; + return Pipeline::Metrics::Shared::runSingle(options, fullCommand, hooks); +} + +int CenTauRService::runBatch(const CenTauRCLIOptions& options, const std::string& fullCommand) const { + Pipeline::Metrics::Shared::BatchRunnerHooks hooks; + hooks.logTag = kLogTag; + hooks.batchOutputSuffix = kBatchOutputSuffix; + hooks.resolveDebugBase = [](const CenTauRCLIOptions& runnerOptions, const std::string& outputPath) { + return runnerOptions.enableDebugOutput + ? Common::path::deriveDebugBasePath(outputPath) + : std::string{}; + }; + hooks.execute = [this](const CenTauRCLIOptions& runnerOptions, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugBase) { + Pipeline::Metrics::Shared::MetricRunResult result; + result.metricResults.push_back( + executeForImage(runnerOptions, inputPath, outputPath, debugBase)); + return result; + }; + hooks.logResults = [](const CenTauRCLIOptions& runnerOptions, + const Pipeline::Metrics::Shared::MetricRunResult& result) { + for (const auto& metric : result.metricResults) { + logMetricResult(metric, runnerOptions.includeSUVr); + } + }; + return Pipeline::Metrics::Shared::runBatch(options, fullCommand, hooks); +} + +void CenTauRService::logMetricResult(const Metrics::MetricResult& result, bool includeSUVr) { + std::cout << "\n=== Refactor CenTauR Results ===" << std::endl; + std::cout << "Metric: " << result.metricName << std::endl; + for (const auto& [tracer, value] : result.tracerValues) { + std::cout << " " << tracer << ": " << value << std::endl; + } + if (includeSUVr) { + std::cout << " SUVr: " << result.suvr << std::endl; + } +} + +std::shared_ptr createService(ServiceContainer& container) { + auto config = container.resolve(); + auto spatialService = container.resolve(); + auto fileService = container.resolve(); + return std::make_shared(config, spatialService, fileService); +} + +} // namespace Pipeline::Metrics::Centaur diff --git a/localizer/src/metrics/centaur/CenTauRService.h b/localizer/src/metrics/centaur/CenTauRService.h new file mode 100644 index 0000000..7b179a7 --- /dev/null +++ b/localizer/src/metrics/centaur/CenTauRService.h @@ -0,0 +1,51 @@ +#pragma once + +#include "../../core/di/ServiceContainer.h" +#include "../../core/interfaces/IConfiguration.h" +#include "../../core/services/IFileService.h" +#include "../../core/services/ISpatialNormalizationService.h" +#include "../shared/MetricTypes.h" +#include +#include + +namespace Pipeline::Metrics::Centaur { + +struct CenTauRCLIOptions { + std::string inputPath; + std::string outputPath; + std::string configPath = "config.toml"; + bool includeSUVr = false; + bool skipRegistration = false; + bool useIterativeRigid = false; + bool useManualFOV = false; + bool enableDebugOutput = false; + bool batchMode = false; + std::string bidsPattern; + std::string debugOutputBasePath; +}; + +class CenTauRService { +public: + CenTauRService(ConfigurationPtr config, + std::shared_ptr spatialService, + std::shared_ptr fileService); + + int run(CenTauRCLIOptions options, const std::string& fullCommand); + +private: + Metrics::MetricResult executeForImage(const CenTauRCLIOptions& options, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugBasePath) const; + int runSingle(const CenTauRCLIOptions& options, const std::string& fullCommand) const; + int runBatch(const CenTauRCLIOptions& options, const std::string& fullCommand) const; + static void logMetricResult(const Metrics::MetricResult& result, bool includeSUVr); + + ConfigurationPtr config_; + std::shared_ptr spatialService_; + std::shared_ptr fileService_; +}; + +std::shared_ptr createService(ServiceContainer& container); + +} // namespace Pipeline::Metrics::Centaur diff --git a/localizer/src/metrics/centaurz/CenTauRzCalculator.cpp b/localizer/src/metrics/centaurz/CenTauRzCalculator.cpp new file mode 100644 index 0000000..47cf192 --- /dev/null +++ b/localizer/src/metrics/centaurz/CenTauRzCalculator.cpp @@ -0,0 +1,65 @@ +#include "CenTauRzCalculator.h" + +#include "../suvr/SUVrCalculator.h" +#include +#include + +Pipeline::Metrics::MetricResult CenTauRzCalculator::calculate(const Input& input) const { + if (!input.spatiallyNormalizedImage) { + throw std::invalid_argument("CenTauRzCalculator requires a spatially normalized image"); + } + if (input.voiMaskPath.empty() || input.refMaskPath.empty()) { + throw std::invalid_argument("CenTauRzCalculator requires VOI and reference masks"); + } + + return calculateRegion( + "CenTauRz", + input.spatiallyNormalizedImage, + input.voiMaskPath, + input.refMaskPath, + input.tracerParameters); +} + +std::vector CenTauRzCalculator::calculateDetailedRegions( + const Input& input) const { + if (!input.spatiallyNormalizedImage) { + throw std::invalid_argument("CenTauRzCalculator requires a spatially normalized image"); + } + if (input.refMaskPath.empty()) { + throw std::invalid_argument("CenTauRzCalculator requires a reference mask"); + } + + std::vector results; + results.reserve(input.detailedRegions.size()); + for (const auto& region : input.detailedRegions) { + if (region.metricName.empty() || region.voiMaskPath.empty()) { + throw std::invalid_argument("CenTauRzCalculator detailed regions require metric names and VOI masks"); + } + results.push_back(calculateRegion( + region.metricName, + input.spatiallyNormalizedImage, + region.voiMaskPath, + input.refMaskPath, + region.tracerParameters)); + } + return results; +} + +Pipeline::Metrics::MetricResult CenTauRzCalculator::calculateRegion( + const std::string& metricName, + ImageType::Pointer spatiallyNormalizedImage, + const std::string& voiMaskPath, + const std::string& refMaskPath, + const std::map& tracerParameters) const { + const double suvr = SUVrCalculator::calculateSUVr( + spatiallyNormalizedImage, voiMaskPath, refMaskPath); + + Pipeline::Metrics::MetricResult result; + result.metricName = metricName; + result.suvr = suvr; + for (const auto& [tracerName, params] : tracerParameters) { + float centaurz = suvr * params.slope + params.intercept; + result.tracerValues[tracerName] = centaurz; + } + return result; +} diff --git a/localizer/src/metrics/centaurz/CenTauRzCalculator.h b/localizer/src/metrics/centaurz/CenTauRzCalculator.h new file mode 100644 index 0000000..e91399a --- /dev/null +++ b/localizer/src/metrics/centaurz/CenTauRzCalculator.h @@ -0,0 +1,38 @@ +#pragma once + +#include "../../core/common/ImageTypes.h" +#include "../shared/MetricTypes.h" +#include +#include +#include + +class CenTauRzCalculator { +public: + struct TracerParams { + float slope; + float intercept; + }; + + struct Input { + ImageType::Pointer spatiallyNormalizedImage; + std::string voiMaskPath; + std::string refMaskPath; + std::map tracerParameters; + struct DetailedRegion { + std::string metricName; + std::string voiMaskPath; + std::map tracerParameters; + }; + std::vector detailedRegions; + }; + + Pipeline::Metrics::MetricResult calculate(const Input& input) const; + std::vector calculateDetailedRegions(const Input& input) const; + +private: + Pipeline::Metrics::MetricResult calculateRegion(const std::string& metricName, + ImageType::Pointer spatiallyNormalizedImage, + const std::string& voiMaskPath, + const std::string& refMaskPath, + const std::map& tracerParameters) const; +}; diff --git a/localizer/src/metrics/centaurz/CentaurzCLI.cpp b/localizer/src/metrics/centaurz/CentaurzCLI.cpp new file mode 100644 index 0000000..027a760 --- /dev/null +++ b/localizer/src/metrics/centaurz/CentaurzCLI.cpp @@ -0,0 +1,107 @@ +#include "CentaurzCLI.h" + +#include "../../core/di/Bootstrap.h" +#include "CentaurzService.h" +#include +#include + +namespace Pipeline::Metrics::Centaurz { + +namespace { + +void addBaseArguments(argparse::ArgumentParser& parser) { + parser.add_argument("--input") + .help("Input PET image path") + .required(); + parser.add_argument("--output") + .help("Output processed image path") + .required(); + parser.add_argument("--config") + .help("Configuration file path") + .default_value(std::string{"config.toml"}); + parser.add_argument("--debug") + .help("Enable debug mode") + .default_value(false) + .implicit_value(true); + parser.add_argument("--batch") + .help("Enable batch processing mode") + .default_value(false) + .implicit_value(true); + parser.add_argument("--bids") + .help("Treat --input as a PET-BIDS dataset root and process PET files matching this regex") + .default_value(std::string{}); +} + +void addSpatialNormalizationArguments(argparse::ArgumentParser& parser) { + parser.add_argument("-i", "--iterative") + .help("Use iterative rigid transformation") + .default_value(false) + .implicit_value(true); + parser.add_argument("-m", "--manual-fov") + .help("Use manual FOV placement") + .default_value(false) + .implicit_value(true); +} + +void addSUVrDerivedArguments(argparse::ArgumentParser& parser) { + addBaseArguments(parser); + addSpatialNormalizationArguments(parser); + parser.add_argument("--suvr") + .help("Include SUVr values in the output") + .default_value(false) + .implicit_value(true); + parser.add_argument("--skip-normalization") + .help("Skip spatial normalization and calculate metrics directly") + .default_value(false) + .implicit_value(true); + parser.add_argument(kDetailedRegionReportFlag) + .help("Report detailed CenTauRz sub-region SUVr and tracer values") + .default_value(false) + .implicit_value(true); +} + +class CentaurzCLI : public IMetricCLI { +public: + std::string getSubcommandName() const override { + return "centaurz"; + } + + std::string getDescription() const override { + return "Prototype CenTauRz pipeline built with service/DI refactor"; + } + + void configureArguments(argparse::ArgumentParser& parser) override { + addSUVrDerivedArguments(parser); + } + + int execute(const argparse::ArgumentParser& parser, const std::string& fullCommand) override { + CentaurzCLIOptions options; + options.inputPath = parser.get("--input"); + options.outputPath = parser.get("--output"); + options.configPath = parser.get("--config"); + options.includeSUVr = parser.get("--suvr"); + options.skipRegistration = parser.get("--skip-normalization"); + options.reportDetailedRegions = parser.get(kDetailedRegionReportFlag); + options.useIterativeRigid = parser.get("--iterative"); + options.useManualFOV = parser.get("--manual-fov"); + options.enableDebugOutput = parser.get("--debug"); + options.batchMode = parser.get("--batch"); + options.bidsPattern = parser.get("--bids"); + + BootstrapOptions bootstrapOptions; + bootstrapOptions.configPath = options.configPath; + bootstrapOptions.enableConfigDebug = options.enableDebugOutput; + bootstrapOptions.logTag = "centaurz"; + auto container = buildCoreContainer(bootstrapOptions); + auto service = createService(*container); + return service->run(options, fullCommand); + } +}; + +} // namespace + +MetricCLIPtr createCLI() { + return std::make_shared(); +} + +} // namespace Pipeline::Metrics::Centaurz diff --git a/localizer/src/metrics/centaurz/CentaurzCLI.h b/localizer/src/metrics/centaurz/CentaurzCLI.h new file mode 100644 index 0000000..a1e512c --- /dev/null +++ b/localizer/src/metrics/centaurz/CentaurzCLI.h @@ -0,0 +1,11 @@ +#pragma once + +#include "../shared/IMetricCLI.h" + +namespace Pipeline::Metrics::Centaurz { + +inline constexpr const char* kDetailedRegionReportFlag = "--report-detailed-regions"; + +MetricCLIPtr createCLI(); + +} // namespace Pipeline::Metrics::Centaurz diff --git a/localizer/src/metrics/centaurz/CentaurzModule.cpp b/localizer/src/metrics/centaurz/CentaurzModule.cpp new file mode 100644 index 0000000..dcaabd7 --- /dev/null +++ b/localizer/src/metrics/centaurz/CentaurzModule.cpp @@ -0,0 +1,11 @@ +#include "CentaurzModule.h" + +#include "CentaurzCLI.h" + +namespace Pipeline::Metrics::Centaurz { + +void registerModule(MetricRegistry& registry) { + registry.registerModule({"centaurz", true, &createCLI}); +} + +} // namespace Pipeline::Metrics::Centaurz diff --git a/localizer/src/metrics/centaurz/CentaurzModule.h b/localizer/src/metrics/centaurz/CentaurzModule.h new file mode 100644 index 0000000..9c27bd2 --- /dev/null +++ b/localizer/src/metrics/centaurz/CentaurzModule.h @@ -0,0 +1,9 @@ +#pragma once + +#include "../shared/MetricRegistry.h" + +namespace Pipeline::Metrics::Centaurz { + +void registerModule(MetricRegistry& registry); + +} // namespace Pipeline::Metrics::Centaurz diff --git a/localizer/src/metrics/centaurz/CentaurzService.cpp b/localizer/src/metrics/centaurz/CentaurzService.cpp new file mode 100644 index 0000000..4d9a41c --- /dev/null +++ b/localizer/src/metrics/centaurz/CentaurzService.cpp @@ -0,0 +1,273 @@ +#include "CentaurzService.h" + +#include "../../core/common/Filesystem.h" +#include "../../core/common/NormalizationContracts.h" +#include "../../metrics/shared/BatchRunner.h" +#include "../../metrics/shared/DebugPathHelpers.h" +#include "../../metrics/shared/MetricRunResult.h" +#include "../../metrics/shared/SingleRunner.h" +#include "CenTauRzCalculator.h" +#include +#include +#include +#include + +namespace Pipeline::Metrics::Centaurz { + +namespace { + +constexpr const char* kLogTag = "centaurz"; +constexpr const char* kBatchOutputSuffix = "_centaurz_refactor.nii"; + +struct TracerConfigSpec { + const char* tracerName; + const char* tracerKey; + float defaultSlope; + float defaultIntercept; +}; + +using TracerConfigSet = std::array; + +constexpr TracerConfigSet kUniversalTracerSpecs = {{{"FTP", "ftp", 13.63f, -15.85f}, + {"GTP1", "gtp1", 10.67f, -11.92f}, + {"MK6240", "mk6240", 10.08f, -10.06f}, + {"PI2620", "pi2620", 8.45f, -9.61f}, + {"RO948", "ro948", 13.05f, -15.57f}, + {"PM-PBB3", "pmpbb3", 16.73f, -15.34f}}}; + +constexpr TracerConfigSet kMesialTemporalTracerSpecs = {{{"FTP", "ftp", 10.42f, -12.11f}, + {"GTP1", "gtp1", 7.88f, -8.75f}, + {"MK6240", "mk6240", 7.28f, -7.01f}, + {"PI2620", "pi2620", 6.03f, -6.83f}, + {"RO948", "ro948", 11.76f, -13.08f}, + {"PM-PBB3", "pmpbb3", 7.97f, -7.83f}}}; + +constexpr TracerConfigSet kMetaTemporalTracerSpecs = {{{"FTP", "ftp", 12.95f, -15.37f}, + {"GTP1", "gtp1", 9.60f, -11.10f}, + {"MK6240", "mk6240", 9.36f, -10.60f}, + {"PI2620", "pi2620", 7.78f, -9.33f}, + {"RO948", "ro948", 13.16f, -16.19f}, + {"PM-PBB3", "pmpbb3", 11.78f, -11.21f}}}; + +constexpr TracerConfigSet kTemporoParietalTracerSpecs = {{{"FTP", "ftp", 13.75f, -15.92f}, + {"GTP1", "gtp1", 10.84f, -12.27f}, + {"MK6240", "mk6240", 9.98f, -10.15f}, + {"PI2620", "pi2620", 8.21f, -9.52f}, + {"RO948", "ro948", 13.05f, -15.62f}, + {"PM-PBB3", "pmpbb3", 16.16f, -14.68f}}}; + +constexpr TracerConfigSet kFrontalTracerSpecs = {{{"FTP", "ftp", 11.61f, -13.01f}, + {"GTP1", "gtp1", 9.41f, -9.71f}, + {"MK6240", "mk6240", 10.05f, -8.91f}, + {"PI2620", "pi2620", 9.07f, -9.01f}, + {"RO948", "ro948", 12.61f, -13.45f}, + {"PM-PBB3", "pmpbb3", 15.70f, -13.18f}}}; + +struct DetailedRegionSpec { + const char* metricName; + const char* maskKey; + const char* configKey; + const TracerConfigSet* tracerSpecs; +}; + +constexpr std::array kDetailedRegionSpecs = {{ + {"CenTauRz.MesialTemporal", + "centaurz_mesial_temporal_voi", + "mesial_temporal", + &kMesialTemporalTracerSpecs}, + {"CenTauRz.MetaTemporal", + "centaurz_meta_temporal_voi", + "meta_temporal", + &kMetaTemporalTracerSpecs}, + {"CenTauRz.TemporoParietal", + "centaurz_temporo_parietal_voi", + "temporo_parietal", + &kTemporoParietalTracerSpecs}, + {"CenTauRz.Frontal", + "centaurz_frontal_voi", + "frontal", + &kFrontalTracerSpecs}, +}}; + +std::string requireMaskPath(ConfigurationPtr config, const std::string& maskKey) { + if (!config) { + throw std::invalid_argument("CenTauRz requires configuration"); + } + const std::string configKey = "masks." + maskKey; + if (config->getString(configKey).empty()) { + throw std::invalid_argument("Missing required configuration key: " + configKey); + } + return config->getMaskPath(maskKey); +} + +std::map loadTracerParameters( + ConfigurationPtr config, + const std::string& baseKey, + const TracerConfigSet& specs) { + if (!config) { + throw std::invalid_argument("CenTauRz requires configuration"); + } + + std::map params; + for (const auto& spec : specs) { + params[spec.tracerName] = { + config->getFloat(baseKey + spec.tracerKey + ".slope", spec.defaultSlope), + config->getFloat(baseKey + spec.tracerKey + ".intercept", spec.defaultIntercept)}; + } + return params; +} + +std::vector loadDetailedRegions(ConfigurationPtr config) { + std::vector regions; + regions.reserve(kDetailedRegionSpecs.size()); + for (const auto& spec : kDetailedRegionSpecs) { + CenTauRzCalculator::Input::DetailedRegion region; + region.metricName = spec.metricName; + region.voiMaskPath = requireMaskPath(config, spec.maskKey); + region.tracerParameters = loadTracerParameters( + config, + "centaurz.detailed_regions." + std::string(spec.configKey) + ".tracers.", + *spec.tracerSpecs); + regions.push_back(std::move(region)); + } + return regions; +} + +SpatialNormalizationRequest buildNormalizationRequest(const CentaurzCLIOptions& options, + const std::string& inputPath, + const std::string& debugBasePath) { + SpatialNormalizationRequest request; + request.inputPath = inputPath; + request.skip = options.skipRegistration; + request.options.useIterativeRigid = options.useIterativeRigid; + request.options.useManualFOV = options.useManualFOV; + request.options.enableDebugOutput = options.enableDebugOutput; + request.options.debugOutputBasePath = options.enableDebugOutput ? debugBasePath : std::string{}; + return request; +} + +} // namespace + +CentaurzService::CentaurzService(ConfigurationPtr config, + std::shared_ptr spatialService, + std::shared_ptr fileService) + : config_(std::move(config)), + spatialService_(std::move(spatialService)), + fileService_(std::move(fileService)) { + if (!config_ || !spatialService_ || !fileService_) { + throw std::invalid_argument("CentaurzService requires configuration, normalization, and file services"); + } +} + +int CentaurzService::run(CentaurzCLIOptions options, const std::string& fullCommand) { + if (!options.batchMode && options.bidsPattern.empty()) { + Pipeline::Metrics::Shared::configureDerivedDebugBasePath(options); + } + if (options.batchMode || !options.bidsPattern.empty()) { + return runBatch(options, fullCommand); + } + return runSingle(options, fullCommand); +} + +Pipeline::Metrics::Shared::MetricRunResult CentaurzService::executeForImage( + const CentaurzCLIOptions& options, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugBasePath) const { + auto normalizationOutput = + spatialService_->normalize(buildNormalizationRequest(options, inputPath, debugBasePath)); + fileService_->saveNormalizedImage({normalizationOutput.spatiallyNormalizedImage, outputPath}); + + CenTauRzCalculator::Input input; + input.spatiallyNormalizedImage = normalizationOutput.spatiallyNormalizedImage; + input.voiMaskPath = requireMaskPath(config_, "centaur_voi"); + input.refMaskPath = requireMaskPath(config_, "centaur_ref"); + input.tracerParameters = loadTracerParameters(config_, "centaurz.tracers.", kUniversalTracerSpecs); + if (options.reportDetailedRegions) { + input.detailedRegions = loadDetailedRegions(config_); + } + + Pipeline::Metrics::Shared::MetricRunResult result; + CenTauRzCalculator calculator; + result.metricResults.push_back(calculator.calculate(input)); + if (options.reportDetailedRegions) { + auto detailedResults = calculator.calculateDetailedRegions(input); + result.metricResults.insert( + result.metricResults.end(), detailedResults.begin(), detailedResults.end()); + } + return result; +} + +int CentaurzService::runSingle(const CentaurzCLIOptions& options, const std::string& fullCommand) const { + if (!Common::fs::ensureParentDirectory(options.outputPath)) { + std::cerr << "[" << kLogTag << "] Failed to prepare output directory: " + << options.outputPath << std::endl; + return EXIT_FAILURE; + } + + Pipeline::Metrics::Shared::SingleRunnerHooks hooks; + hooks.logTag = kLogTag; + hooks.resolveDebugBase = [](const CentaurzCLIOptions& runnerOptions, const std::string&) { + return runnerOptions.enableDebugOutput ? runnerOptions.debugOutputBasePath : std::string{}; + }; + hooks.execute = [this](const CentaurzCLIOptions& runnerOptions, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugBase) { + return executeForImage(runnerOptions, inputPath, outputPath, debugBase); + }; + hooks.logResults = [](const CentaurzCLIOptions& runnerOptions, + const Pipeline::Metrics::Shared::MetricRunResult& result) { + std::cout << "\n[" << kLogTag + << "] Spatial normalization complete. Normalized image saved to " + << runnerOptions.outputPath << std::endl; + for (const auto& metric : result.metricResults) { + logMetricResult(metric, runnerOptions.includeSUVr); + } + }; + return Pipeline::Metrics::Shared::runSingle(options, fullCommand, hooks); +} + +int CentaurzService::runBatch(const CentaurzCLIOptions& options, const std::string& fullCommand) const { + Pipeline::Metrics::Shared::BatchRunnerHooks hooks; + hooks.logTag = kLogTag; + hooks.batchOutputSuffix = kBatchOutputSuffix; + hooks.resolveDebugBase = [](const CentaurzCLIOptions& runnerOptions, const std::string& outputPath) { + return runnerOptions.enableDebugOutput + ? Common::path::deriveDebugBasePath(outputPath) + : std::string{}; + }; + hooks.execute = [this](const CentaurzCLIOptions& runnerOptions, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugBase) { + return executeForImage(runnerOptions, inputPath, outputPath, debugBase); + }; + hooks.logResults = [](const CentaurzCLIOptions& runnerOptions, + const Pipeline::Metrics::Shared::MetricRunResult& result) { + for (const auto& metric : result.metricResults) { + logMetricResult(metric, runnerOptions.includeSUVr); + } + }; + return Pipeline::Metrics::Shared::runBatch(options, fullCommand, hooks); +} + +void CentaurzService::logMetricResult(const Metrics::MetricResult& result, bool includeSUVr) { + std::cout << "\n=== Refactor CenTauRz Results ===" << std::endl; + std::cout << "Metric: " << result.metricName << std::endl; + for (const auto& [tracer, value] : result.tracerValues) { + std::cout << " " << tracer << ": " << value << std::endl; + } + if (includeSUVr || result.metricName != "CenTauRz") { + std::cout << " SUVr: " << result.suvr << std::endl; + } +} + +std::shared_ptr createService(ServiceContainer& container) { + auto config = container.resolve(); + auto spatialService = container.resolve(); + auto fileService = container.resolve(); + return std::make_shared(config, spatialService, fileService); +} + +} // namespace Pipeline::Metrics::Centaurz diff --git a/localizer/src/metrics/centaurz/CentaurzService.h b/localizer/src/metrics/centaurz/CentaurzService.h new file mode 100644 index 0000000..689fdb8 --- /dev/null +++ b/localizer/src/metrics/centaurz/CentaurzService.h @@ -0,0 +1,53 @@ +#pragma once + +#include "../../core/di/ServiceContainer.h" +#include "../../core/interfaces/IConfiguration.h" +#include "../../core/services/IFileService.h" +#include "../../core/services/ISpatialNormalizationService.h" +#include "../shared/MetricRunResult.h" +#include "../shared/MetricTypes.h" +#include +#include + +namespace Pipeline::Metrics::Centaurz { + +struct CentaurzCLIOptions { + std::string inputPath; + std::string outputPath; + std::string configPath = "config.toml"; + bool includeSUVr = false; + bool skipRegistration = false; + bool useIterativeRigid = false; + bool useManualFOV = false; + bool enableDebugOutput = false; + bool batchMode = false; + std::string bidsPattern; + bool reportDetailedRegions = false; + std::string debugOutputBasePath; +}; + +class CentaurzService { +public: + CentaurzService(ConfigurationPtr config, + std::shared_ptr spatialService, + std::shared_ptr fileService); + + int run(CentaurzCLIOptions options, const std::string& fullCommand); + +private: + Pipeline::Metrics::Shared::MetricRunResult executeForImage(const CentaurzCLIOptions& options, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugBasePath) const; + int runSingle(const CentaurzCLIOptions& options, const std::string& fullCommand) const; + int runBatch(const CentaurzCLIOptions& options, const std::string& fullCommand) const; + static void logMetricResult(const Metrics::MetricResult& result, bool includeSUVr); + + ConfigurationPtr config_; + std::shared_ptr spatialService_; + std::shared_ptr fileService_; +}; + +std::shared_ptr createService(ServiceContainer& container); + +} // namespace Pipeline::Metrics::Centaurz diff --git a/localizer/src/metrics/centiloid/CentiloidCLI.cpp b/localizer/src/metrics/centiloid/CentiloidCLI.cpp new file mode 100644 index 0000000..f6ca9a9 --- /dev/null +++ b/localizer/src/metrics/centiloid/CentiloidCLI.cpp @@ -0,0 +1,102 @@ +#include "CentiloidCLI.h" + +#include "../../core/di/Bootstrap.h" +#include "CentiloidService.h" +#include +#include + +namespace Pipeline::Metrics::Centiloid { + +namespace { + +void addBaseArguments(argparse::ArgumentParser& parser) { + parser.add_argument("--input") + .help("Input PET image path") + .required(); + parser.add_argument("--output") + .help("Output processed image path") + .required(); + parser.add_argument("--config") + .help("Configuration file path") + .default_value(std::string{"config.toml"}); + parser.add_argument("--debug") + .help("Enable debug mode") + .default_value(false) + .implicit_value(true); + parser.add_argument("--batch") + .help("Enable batch processing mode") + .default_value(false) + .implicit_value(true); + parser.add_argument("--bids") + .help("Treat --input as a PET-BIDS dataset root and process PET files matching this regex") + .default_value(std::string{}); +} + +void addSpatialNormalizationArguments(argparse::ArgumentParser& parser) { + parser.add_argument("-i", "--iterative") + .help("Use iterative rigid transformation") + .default_value(false) + .implicit_value(true); + parser.add_argument("-m", "--manual-fov") + .help("Use manual FOV placement") + .default_value(false) + .implicit_value(true); +} + +void addSUVrDerivedArguments(argparse::ArgumentParser& parser) { + addBaseArguments(parser); + addSpatialNormalizationArguments(parser); + parser.add_argument("--suvr") + .help("Include SUVr values in the output") + .default_value(false) + .implicit_value(true); + parser.add_argument("--skip-normalization") + .help("Skip spatial normalization and calculate metrics directly") + .default_value(false) + .implicit_value(true); +} + +class CentiloidCLI : public IMetricCLI { +public: + std::string getSubcommandName() const override { + return "centiloid"; + } + + std::string getDescription() const override { + return "Centiloid quantification pipeline powered by the service/DI stack"; + } + + void configureArguments(argparse::ArgumentParser& parser) override { + addSUVrDerivedArguments(parser); + } + + int execute(const argparse::ArgumentParser& parser, const std::string& fullCommand) override { + CentiloidCLIOptions options; + options.inputPath = parser.get("--input"); + options.outputPath = parser.get("--output"); + options.configPath = parser.get("--config"); + options.includeSUVr = parser.get("--suvr"); + options.skipRegistration = parser.get("--skip-normalization"); + options.useIterativeRigid = parser.get("--iterative"); + options.useManualFOV = parser.get("--manual-fov"); + options.enableDebugOutput = parser.get("--debug"); + options.batchMode = parser.get("--batch"); + options.bidsPattern = parser.get("--bids"); + + BootstrapOptions bootstrapOptions; + bootstrapOptions.configPath = options.configPath; + bootstrapOptions.enableConfigDebug = options.enableDebugOutput; + bootstrapOptions.logTag = "centiloid"; + auto container = buildCoreContainer(bootstrapOptions); + auto service = createService(*container); + return service->run(options, fullCommand); + } +}; + +} // namespace + +MetricCLIPtr createCLI() { + return std::make_shared(); +} + +} // namespace Pipeline::Metrics::Centiloid diff --git a/localizer/src/metrics/centiloid/CentiloidCLI.h b/localizer/src/metrics/centiloid/CentiloidCLI.h new file mode 100644 index 0000000..49c44ff --- /dev/null +++ b/localizer/src/metrics/centiloid/CentiloidCLI.h @@ -0,0 +1,9 @@ +#pragma once + +#include "../shared/IMetricCLI.h" + +namespace Pipeline::Metrics::Centiloid { + +MetricCLIPtr createCLI(); + +} // namespace Pipeline::Metrics::Centiloid diff --git a/localizer/src/metrics/centiloid/CentiloidCalculator.cpp b/localizer/src/metrics/centiloid/CentiloidCalculator.cpp new file mode 100644 index 0000000..0c53f8a --- /dev/null +++ b/localizer/src/metrics/centiloid/CentiloidCalculator.cpp @@ -0,0 +1,31 @@ +#include "CentiloidCalculator.h" + +#include "../suvr/SUVrCalculator.h" +#include +#include + +Pipeline::Metrics::MetricResult CentiloidCalculator::calculate(const Input& input) const { + if (!input.spatiallyNormalizedImage) { + throw std::invalid_argument("CentiloidCalculator requires a spatially normalized image"); + } + if (input.voiMaskPath.empty() || input.refMaskPath.empty()) { + throw std::invalid_argument("CentiloidCalculator requires VOI and reference masks"); + } + + const double suvr = SUVrCalculator::calculateSUVr( + input.spatiallyNormalizedImage, input.voiMaskPath, input.refMaskPath); + + Pipeline::Metrics::MetricResult result; + result.metricName = "Centiloid"; + result.suvr = suvr; + + for (const auto& [tracerName, params] : input.tracerParameters) { + result.tracerValues[tracerName] = suvr * params.slope + params.intercept; + } + + return result; +} + +std::vector CentiloidCalculator::supportedTracers() { + return {"PiB", "FBP", "FBB", "FMM", "NAV"}; +} diff --git a/localizer/src/metrics/centiloid/CentiloidCalculator.h b/localizer/src/metrics/centiloid/CentiloidCalculator.h new file mode 100644 index 0000000..49f95e6 --- /dev/null +++ b/localizer/src/metrics/centiloid/CentiloidCalculator.h @@ -0,0 +1,25 @@ +#pragma once + +#include "../../core/common/ImageTypes.h" +#include "../shared/MetricTypes.h" +#include +#include +#include + +class CentiloidCalculator { +public: + struct TracerParams { + float slope; + float intercept; + }; + + struct Input { + ImageType::Pointer spatiallyNormalizedImage; + std::string voiMaskPath; + std::string refMaskPath; + std::map tracerParameters; + }; + + Pipeline::Metrics::MetricResult calculate(const Input& input) const; + static std::vector supportedTracers(); +}; diff --git a/localizer/src/metrics/centiloid/CentiloidModule.cpp b/localizer/src/metrics/centiloid/CentiloidModule.cpp new file mode 100644 index 0000000..eb97202 --- /dev/null +++ b/localizer/src/metrics/centiloid/CentiloidModule.cpp @@ -0,0 +1,11 @@ +#include "CentiloidModule.h" + +#include "CentiloidCLI.h" + +namespace Pipeline::Metrics::Centiloid { + +void registerModule(MetricRegistry& registry) { + registry.registerModule({"centiloid", true, &createCLI}); +} + +} // namespace Pipeline::Metrics::Centiloid diff --git a/localizer/src/metrics/centiloid/CentiloidModule.h b/localizer/src/metrics/centiloid/CentiloidModule.h new file mode 100644 index 0000000..af744ca --- /dev/null +++ b/localizer/src/metrics/centiloid/CentiloidModule.h @@ -0,0 +1,9 @@ +#pragma once + +#include "../shared/MetricRegistry.h" + +namespace Pipeline::Metrics::Centiloid { + +void registerModule(MetricRegistry& registry); + +} // namespace Pipeline::Metrics::Centiloid diff --git a/localizer/src/metrics/centiloid/CentiloidService.cpp b/localizer/src/metrics/centiloid/CentiloidService.cpp new file mode 100644 index 0000000..fb95f39 --- /dev/null +++ b/localizer/src/metrics/centiloid/CentiloidService.cpp @@ -0,0 +1,183 @@ +#include "CentiloidService.h" + +#include "../../core/common/Filesystem.h" +#include "../../core/common/NormalizationContracts.h" +#include "../../core/config/Version.h" +#include "../../metrics/shared/BatchLogging.h" +#include "../../metrics/shared/BatchRunner.h" +#include "../../metrics/shared/DebugPathHelpers.h" +#include "../../metrics/shared/MetricRunResult.h" +#include "../../metrics/shared/SingleRunner.h" +#include "CentiloidCalculator.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Pipeline::Metrics::Centiloid { + +namespace { + +constexpr const char* kLogTag = "centiloid"; +constexpr const char* kBatchOutputSuffix = "_centiloid.nii"; + +std::map loadTracerParameters(ConfigurationPtr config) { + if (!config) { + throw std::invalid_argument("Centiloid requires configuration"); + } + + std::map params; + for (const auto& tracer : CentiloidCalculator::supportedTracers()) { + std::string tracerLower = tracer; + std::transform( + tracerLower.begin(), + tracerLower.end(), + tracerLower.begin(), + [](unsigned char ch) { return static_cast(std::tolower(ch)); }); + params[tracer] = CentiloidCalculator::TracerParams{ + config->getFloat("centiloid.tracers." + tracerLower + ".slope"), + config->getFloat("centiloid.tracers." + tracerLower + ".intercept"), + }; + } + + return params; +} + +SpatialNormalizationRequest buildNormalizationRequest(const CentiloidCLIOptions& options, + const std::string& inputPath, + const std::string& debugBasePath) { + SpatialNormalizationRequest request; + request.inputPath = inputPath; + request.skip = options.skipRegistration; + request.options.useIterativeRigid = options.useIterativeRigid; + request.options.useManualFOV = options.useManualFOV; + request.options.enableDebugOutput = options.enableDebugOutput; + request.options.debugOutputBasePath = options.enableDebugOutput ? debugBasePath : std::string{}; + return request; +} + +} // namespace + +CentiloidService::CentiloidService(ConfigurationPtr config, + std::shared_ptr spatialService, + std::shared_ptr fileService) + : config_(std::move(config)), + spatialService_(std::move(spatialService)), + fileService_(std::move(fileService)) { + if (!config_ || !spatialService_ || !fileService_) { + throw std::invalid_argument("CentiloidService requires configuration, normalization, and file services"); + } +} + +int CentiloidService::run(CentiloidCLIOptions options, const std::string& fullCommand) { + Pipeline::Metrics::Shared::configureDerivedDebugBasePath(options); + if (options.batchMode || !options.bidsPattern.empty()) { + return runBatch(options, fullCommand); + } + return runSingle(options, fullCommand); +} + +Metrics::MetricResult CentiloidService::executeForImage(const CentiloidCLIOptions& options, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugBasePath) const { + auto normalizationOutput = + spatialService_->normalize(buildNormalizationRequest(options, inputPath, debugBasePath)); + fileService_->saveNormalizedImage({normalizationOutput.spatiallyNormalizedImage, outputPath}); + + CentiloidCalculator calculator; + CentiloidCalculator::Input input; + input.spatiallyNormalizedImage = normalizationOutput.spatiallyNormalizedImage; + input.voiMaskPath = config_->getMaskPath("centiloid_voi"); + input.refMaskPath = config_->getMaskPath("whole_cerebral"); + input.tracerParameters = loadTracerParameters(config_); + return calculator.calculate(input); +} + +int CentiloidService::runSingle(const CentiloidCLIOptions& options, + const std::string& fullCommand) const { + if (!Common::fs::ensureParentDirectory(options.outputPath)) { + std::cerr << "[" << kLogTag << "] Failed to prepare output directory: " + << options.outputPath << std::endl; + return EXIT_FAILURE; + } + + Pipeline::Metrics::Shared::SingleRunnerHooks hooks; + hooks.logTag = kLogTag; + hooks.resolveDebugBase = [](const CentiloidCLIOptions& runnerOptions, const std::string& outputPath) { + return runnerOptions.enableDebugOutput + ? Common::path::deriveDebugBasePath(outputPath) + : std::string{}; + }; + hooks.execute = [this](const CentiloidCLIOptions& runnerOptions, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugBase) { + Pipeline::Metrics::Shared::MetricRunResult result; + result.metricResults.push_back( + executeForImage(runnerOptions, inputPath, outputPath, debugBase)); + return result; + }; + hooks.logResults = [this](const CentiloidCLIOptions& runnerOptions, + const Pipeline::Metrics::Shared::MetricRunResult& result) { + std::cout << "\n[" << kLogTag + << "] Spatial normalization complete. Normalized image saved to " + << runnerOptions.outputPath << std::endl; + for (const auto& metric : result.metricResults) { + logMetricResult(metric, runnerOptions.includeSUVr); + } + }; + return Pipeline::Metrics::Shared::runSingle(options, fullCommand, hooks); +} + +int CentiloidService::runBatch(const CentiloidCLIOptions& options, + const std::string& fullCommand) const { + Pipeline::Metrics::Shared::BatchRunnerHooks hooks; + hooks.logTag = kLogTag; + hooks.batchOutputSuffix = kBatchOutputSuffix; + hooks.resolveDebugBase = [](const CentiloidCLIOptions& runnerOptions, const std::string& outputPath) { + return runnerOptions.enableDebugOutput + ? Common::path::deriveDebugBasePath(outputPath) + : std::string{}; + }; + hooks.execute = [this](const CentiloidCLIOptions& runnerOptions, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugBase) { + Pipeline::Metrics::Shared::MetricRunResult result; + result.metricResults.push_back( + executeForImage(runnerOptions, inputPath, outputPath, debugBase)); + return result; + }; + hooks.logResults = [this](const CentiloidCLIOptions& runnerOptions, + const Pipeline::Metrics::Shared::MetricRunResult& result) { + for (const auto& metric : result.metricResults) { + logMetricResult(metric, runnerOptions.includeSUVr); + } + }; + return Pipeline::Metrics::Shared::runBatch(options, fullCommand, hooks); +} + +void CentiloidService::logMetricResult(const Metrics::MetricResult& result, bool includeSUVr) const { + std::cout << "\n=== Centiloid Results ===" << std::endl; + std::cout << "Metric: " << result.metricName << std::endl; + for (const auto& [tracer, value] : result.tracerValues) { + std::cout << " " << tracer << ": " << value << std::endl; + } + if (includeSUVr) { + std::cout << " SUVr: " << result.suvr << std::endl; + } +} + +std::shared_ptr createService(ServiceContainer& container) { + auto config = container.resolve(); + auto spatialService = container.resolve(); + auto fileService = container.resolve(); + return std::make_shared(config, spatialService, fileService); +} + +} // namespace Pipeline::Metrics::Centiloid diff --git a/localizer/src/metrics/centiloid/CentiloidService.h b/localizer/src/metrics/centiloid/CentiloidService.h new file mode 100644 index 0000000..5721f78 --- /dev/null +++ b/localizer/src/metrics/centiloid/CentiloidService.h @@ -0,0 +1,52 @@ +#pragma once + +#include "../../core/di/ServiceContainer.h" +#include "../../core/interfaces/IConfiguration.h" +#include "../../core/services/IFileService.h" +#include "../../core/services/ISpatialNormalizationService.h" +#include "../../metrics/shared/MetricTypes.h" +#include +#include + +namespace Pipeline::Metrics::Centiloid { + +struct CentiloidCLIOptions { + std::string inputPath; + std::string outputPath; + std::string configPath = "config.toml"; + bool includeSUVr = false; + bool skipRegistration = false; + bool useIterativeRigid = false; + bool useManualFOV = false; + bool enableDebugOutput = false; + bool batchMode = false; + std::string bidsPattern; + std::string debugOutputBasePath; +}; + +class CentiloidService { +public: + CentiloidService(ConfigurationPtr config, + std::shared_ptr spatialService, + std::shared_ptr fileService); + + int run(CentiloidCLIOptions options, const std::string& fullCommand); + +private: + Metrics::MetricResult executeForImage(const CentiloidCLIOptions& options, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugBasePath) const; + + int runSingle(const CentiloidCLIOptions& options, const std::string& fullCommand) const; + int runBatch(const CentiloidCLIOptions& options, const std::string& fullCommand) const; + void logMetricResult(const Metrics::MetricResult& result, bool includeSUVr) const; + + ConfigurationPtr config_; + std::shared_ptr spatialService_; + std::shared_ptr fileService_; +}; + +std::shared_ptr createService(ServiceContainer& container); + +} // namespace Pipeline::Metrics::Centiloid diff --git a/localizer/src/metrics/fillstates/FillStatesCLI.cpp b/localizer/src/metrics/fillstates/FillStatesCLI.cpp new file mode 100644 index 0000000..882b6f6 --- /dev/null +++ b/localizer/src/metrics/fillstates/FillStatesCLI.cpp @@ -0,0 +1,99 @@ +#include "FillStatesCLI.h" + +#include "../../core/di/Bootstrap.h" +#include "FillStatesService.h" +#include +#include + +namespace Pipeline::Metrics::FillStates { + +namespace { + +void addBaseArguments(argparse::ArgumentParser& parser) { + parser.add_argument("--input") + .help("Input PET image path") + .required(); + parser.add_argument("--output") + .help("Output processed image path") + .required(); + parser.add_argument("--config") + .help("Configuration file path") + .default_value(std::string{"config.toml"}); + parser.add_argument("--debug") + .help("Enable debug mode") + .default_value(false) + .implicit_value(true); +} + +void addSpatialNormalizationArguments(argparse::ArgumentParser& parser) { + parser.add_argument("-i", "--iterative") + .help("Use iterative rigid transformation") + .default_value(false) + .implicit_value(true); + parser.add_argument("-m", "--manual-fov") + .help("Use manual FOV placement") + .default_value(false) + .implicit_value(true); +} + +void addFillStatesArguments(argparse::ArgumentParser& parser) { + addBaseArguments(parser); + addSpatialNormalizationArguments(parser); + parser.add_argument("--suvr") + .help("Include SUVr values in the output") + .default_value(false) + .implicit_value(true); + parser.add_argument("--skip-normalization") + .help("Skip spatial normalization and calculate metrics directly") + .default_value(false) + .implicit_value(true); + parser.add_argument("--tracer") + .help("Tracer type to use for fill-states metric (fbp, fdg, ftp)") + .required() + .choices("fbp", "fdg", "ftp"); +} + +class FillStatesCLI : public IMetricCLI { +public: + std::string getSubcommandName() const override { + return "fillstates"; + } + + std::string getDescription() const override { + return "Prototype FillStates pipeline built with service/DI refactor"; + } + + void configureArguments(argparse::ArgumentParser& parser) override { + addFillStatesArguments(parser); + } + + int execute(const argparse::ArgumentParser& parser, const std::string& fullCommand) override { + FillStatesCLIOptions options; + options.inputPath = parser.get("--input"); + options.outputPath = parser.get("--output"); + options.configPath = parser.get("--config"); + options.tracer = parser.get("--tracer"); + options.includeSUVr = parser.get("--suvr"); + options.skipRegistration = parser.get("--skip-normalization"); + options.useIterativeRigid = parser.get("--iterative"); + options.useManualFOV = parser.get("--manual-fov"); + options.enableDebugOutput = parser.get("--debug"); + + BootstrapOptions bootstrapOptions; + bootstrapOptions.configPath = options.configPath; + bootstrapOptions.enableConfigDebug = options.enableDebugOutput; + bootstrapOptions.logTag = "fillstates"; + auto container = buildCoreContainer(bootstrapOptions); + auto service = createService(*container); + return service->run(options, fullCommand); + } +}; + +} // namespace + +MetricCLIPtr createCLI() { + return std::make_shared(); +} + +} // namespace Pipeline::Metrics::FillStates + diff --git a/localizer/src/metrics/fillstates/FillStatesCLI.h b/localizer/src/metrics/fillstates/FillStatesCLI.h new file mode 100644 index 0000000..bb574b5 --- /dev/null +++ b/localizer/src/metrics/fillstates/FillStatesCLI.h @@ -0,0 +1,10 @@ +#pragma once + +#include "../shared/IMetricCLI.h" + +namespace Pipeline::Metrics::FillStates { + +MetricCLIPtr createCLI(); + +} // namespace Pipeline::Metrics::FillStates + diff --git a/localizer/src/calculators/FillStatesCalculator.cpp b/localizer/src/metrics/fillstates/FillStatesCalculator.cpp similarity index 79% rename from localizer/src/calculators/FillStatesCalculator.cpp rename to localizer/src/metrics/fillstates/FillStatesCalculator.cpp index 28f7cf1..7cc44bb 100644 --- a/localizer/src/calculators/FillStatesCalculator.cpp +++ b/localizer/src/metrics/fillstates/FillStatesCalculator.cpp @@ -1,5 +1,7 @@ #include "FillStatesCalculator.h" +#include "../../core/common/Common.h" +#include #include #include #include @@ -40,10 +42,11 @@ FillStatesCalculator::TracerResources FillStatesCalculator::getTracerResources() "'. Please set fillstates.tracers." + t + ".mean/std/roi in config."); } - const std::string execDir = Common::getExecutablePath(); - res.meanPath = (std::filesystem::path(execDir) / meanRel).string(); - res.stdPath = (std::filesystem::path(execDir) / stdRel).string(); - res.roiPath = (std::filesystem::path(execDir) / roiRel).string(); + const std::string execDir = Common::path::executableDirectory(); + const std::filesystem::path execDirPath = Common::path::fromUtf8(execDir); + res.meanPath = Common::path::toUtf8(execDirPath / Common::path::fromUtf8(meanRel)); + res.stdPath = Common::path::toUtf8(execDirPath / Common::path::fromUtf8(stdRel)); + res.roiPath = Common::path::toUtf8(execDirPath / Common::path::fromUtf8(roiRel)); if (t == "fbp" || t == "fdg") { res.refMaskKey = "whole_cerebral"; // Centiloid-style reference @@ -56,7 +59,7 @@ FillStatesCalculator::TracerResources FillStatesCalculator::getTracerResources() return res; } -MetricResult FillStatesCalculator::calculate(ImageType::Pointer spatialNormalizedImage) { +Pipeline::Metrics::MetricResult FillStatesCalculator::calculate(ImageType::Pointer spatialNormalizedImage) { if (!spatialNormalizedImage) { throw std::invalid_argument("FillStatesCalculator::calculate received null image."); } @@ -68,29 +71,29 @@ MetricResult FillStatesCalculator::calculate(ImageType::Pointer spatialNormalize const TracerResources resources = getTracerResources(); // Load mean/std and ROI images - ImageType::Pointer meanImage = Common::LoadNii(resources.meanPath); - ImageType::Pointer stdImage = Common::LoadNii(resources.stdPath); + ImageType::Pointer meanImage = Common::nifti::loadImage(resources.meanPath); + ImageType::Pointer stdImage = Common::nifti::loadImage(resources.stdPath); ImageType::Pointer roiImage; if (!resources.roiPath.empty()) { - roiImage = Common::LoadNii(resources.roiPath); + roiImage = Common::nifti::loadImage(resources.roiPath); } else if (!resources.roiMaskKey.empty()) { - roiImage = Common::LoadNii(config_->getMaskPath(resources.roiMaskKey)); + roiImage = Common::nifti::loadImage(config_->getMaskPath(resources.roiMaskKey)); } else { throw std::runtime_error("No ROI specified for fill-states calculation."); } // Resample all auxiliary images to match the spatially normalized image - ImageType::Pointer meanResampled = Common::ResampleToMatch(spatialNormalizedImage, meanImage); - ImageType::Pointer stdResampled = Common::ResampleToMatch(spatialNormalizedImage, stdImage); - ImageType::Pointer roiResampled = Common::ResampleToMatch(spatialNormalizedImage, roiImage); + ImageType::Pointer meanResampled = Common::image::resampleToMatch(spatialNormalizedImage, meanImage); + ImageType::Pointer stdResampled = Common::image::resampleToMatch(spatialNormalizedImage, stdImage); + ImageType::Pointer roiResampled = Common::image::resampleToMatch(spatialNormalizedImage, roiImage); // Intensity normalization using tracer-specific reference ROI, if provided double refMean = 1.0; if (!resources.refMaskKey.empty()) { - ImageType::Pointer refTemplate = Common::LoadNii(config_->getMaskPath(resources.refMaskKey)); - ImageType::Pointer imageInRefSpace = Common::ResampleToMatch(refTemplate, spatialNormalizedImage); - refMean = Common::CalculateMeanInMask(imageInRefSpace, refTemplate); + ImageType::Pointer refTemplate = Common::nifti::loadImage(config_->getMaskPath(resources.refMaskKey)); + ImageType::Pointer imageInRefSpace = Common::image::resampleToMatch(refTemplate, spatialNormalizedImage); + refMean = Common::image::calculateMeanInMask(imageInRefSpace, refTemplate); if (refMean <= 0.0) { throw std::runtime_error("Reference region mean is non-positive for fill-states."); } @@ -164,7 +167,7 @@ MetricResult FillStatesCalculator::calculate(ImageType::Pointer spatialNormalize } } - MetricResult result; + Pipeline::Metrics::MetricResult result; result.metricName = "FillStates"; result.suvr = 0.0; // Not defined for fill-states; reserved for future use. @@ -190,13 +193,3 @@ MetricResult FillStatesCalculator::calculate(ImageType::Pointer spatialNormalize return result; } - -std::string FillStatesCalculator::getName() const { - return "FillStates"; -} - -std::vector FillStatesCalculator::getSupportedTracers() const { - return {"fbp", "fdg", "ftp"}; -} - - diff --git a/localizer/src/calculators/FillStatesCalculator.h b/localizer/src/metrics/fillstates/FillStatesCalculator.h similarity index 76% rename from localizer/src/calculators/FillStatesCalculator.h rename to localizer/src/metrics/fillstates/FillStatesCalculator.h index cdf98db..508f3c3 100644 --- a/localizer/src/calculators/FillStatesCalculator.h +++ b/localizer/src/metrics/fillstates/FillStatesCalculator.h @@ -1,7 +1,8 @@ #pragma once -#include "../interfaces/IMetricCalculator.h" -#include "../interfaces/IConfiguration.h" -#include "../utils/common.h" + +#include "../../core/common/ImageTypes.h" +#include "../../core/interfaces/IConfiguration.h" +#include "../shared/MetricTypes.h" #include #include /** @@ -10,14 +11,12 @@ * Computes the proportion of suprathreshold voxels within a meta-ROI based on * voxel-wise z-score maps derived from tracer-specific mean/std templates. */ -class FillStatesCalculator : public IMetricCalculator { +class FillStatesCalculator { public: explicit FillStatesCalculator(ConfigurationPtr config); - virtual ~FillStatesCalculator() = default; + ~FillStatesCalculator() = default; - MetricResult calculate(ImageType::Pointer spatialNormalizedImage) override; - std::string getName() const override; - std::vector getSupportedTracers() const override; + Pipeline::Metrics::MetricResult calculate(ImageType::Pointer spatialNormalizedImage); /** * @brief Set tracer name used for this calculation. @@ -48,4 +47,3 @@ class FillStatesCalculator : public IMetricCalculator { static std::string toLowerCopy(const std::string& v); }; - diff --git a/localizer/src/metrics/fillstates/FillStatesModule.cpp b/localizer/src/metrics/fillstates/FillStatesModule.cpp new file mode 100644 index 0000000..e17b4a3 --- /dev/null +++ b/localizer/src/metrics/fillstates/FillStatesModule.cpp @@ -0,0 +1,11 @@ +#include "FillStatesModule.h" + +#include "FillStatesCLI.h" + +namespace Pipeline::Metrics::FillStates { + +void registerModule(MetricRegistry& registry) { + registry.registerModule({"fillstates", true, &createCLI}); +} + +} // namespace Pipeline::Metrics::FillStates diff --git a/localizer/src/metrics/fillstates/FillStatesModule.h b/localizer/src/metrics/fillstates/FillStatesModule.h new file mode 100644 index 0000000..6b41aff --- /dev/null +++ b/localizer/src/metrics/fillstates/FillStatesModule.h @@ -0,0 +1,9 @@ +#pragma once + +#include "../shared/MetricRegistry.h" + +namespace Pipeline::Metrics::FillStates { + +void registerModule(MetricRegistry& registry); + +} // namespace Pipeline::Metrics::FillStates diff --git a/localizer/src/metrics/fillstates/FillStatesService.cpp b/localizer/src/metrics/fillstates/FillStatesService.cpp new file mode 100644 index 0000000..7755ad9 --- /dev/null +++ b/localizer/src/metrics/fillstates/FillStatesService.cpp @@ -0,0 +1,143 @@ +#include "FillStatesService.h" + +#include "../../core/common/Common.h" +#include "../../core/common/Filesystem.h" +#include "../../core/common/NormalizationContracts.h" +#include "../../metrics/shared/DebugPathHelpers.h" +#include "../../metrics/shared/MetricRunResult.h" +#include "../../metrics/shared/SingleRunner.h" +#include "FillStatesCalculator.h" +#include +#include +#include + +namespace Pipeline::Metrics::FillStates { + +namespace { + +constexpr const char* kLogTag = "fillstates"; + +SpatialNormalizationRequest buildNormalizationRequest(const FillStatesCLIOptions& options, + const std::string& inputPath, + const std::string& debugBasePath) { + SpatialNormalizationRequest request; + request.inputPath = inputPath; + request.skip = options.skipRegistration; + request.options.useIterativeRigid = options.useIterativeRigid; + request.options.useManualFOV = options.useManualFOV; + request.options.enableDebugOutput = options.enableDebugOutput; + request.options.debugOutputBasePath = options.enableDebugOutput ? debugBasePath : std::string{}; + return request; +} + +} // namespace + +FillStatesService::FillStatesService(ConfigurationPtr config, + std::shared_ptr spatialService, + std::shared_ptr fileService) + : config_(std::move(config)), + spatialService_(std::move(spatialService)), + fileService_(std::move(fileService)) { + if (!config_ || !spatialService_ || !fileService_) { + throw std::invalid_argument("FillStatesService requires configuration, normalization, and file services"); + } +} + +int FillStatesService::run(FillStatesCLIOptions options, const std::string& fullCommand) { + if (options.batchMode) { + std::cerr << "[fillstates] Batch mode is not supported." << std::endl; + return EXIT_FAILURE; + } + if (options.tracer.empty()) { + std::cerr << "[fillstates] --tracer is required." << std::endl; + return EXIT_FAILURE; + } + + Pipeline::Metrics::Shared::configureDerivedDebugBasePath(options); + return runSingle(options, fullCommand); +} + +Metrics::MetricResult FillStatesService::executeForImage(const FillStatesCLIOptions& options, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugBasePath) const { + auto normalizationOutput = + spatialService_->normalize(buildNormalizationRequest(options, inputPath, debugBasePath)); + fileService_->saveNormalizedImage({normalizationOutput.spatiallyNormalizedImage, outputPath}); + + FillStatesCalculator calculator(config_); + calculator.setTracer(options.tracer); + auto result = calculator.calculate(normalizationOutput.spatiallyNormalizedImage); + + const std::string maskOutputPath = buildMaskOutputPath(outputPath); + if (auto mask = calculator.getLastMaskImage(); mask && !maskOutputPath.empty()) { + Common::fs::ensureParentDirectory(maskOutputPath); + Common::nifti::saveImage(mask, maskOutputPath); + std::cout << "[fillstates] Fill-states mask saved to " << maskOutputPath << std::endl; + } + + return result; +} + +int FillStatesService::runSingle(const FillStatesCLIOptions& options, + const std::string& fullCommand) const { + if (!Common::fs::ensureParentDirectory(options.outputPath)) { + std::cerr << "[" << kLogTag << "] Failed to prepare output directory: " + << options.outputPath << std::endl; + return EXIT_FAILURE; + } + + Pipeline::Metrics::Shared::SingleRunnerHooks hooks; + hooks.logTag = kLogTag; + hooks.resolveDebugBase = [](const FillStatesCLIOptions& runnerOptions, const std::string& outputPath) { + return runnerOptions.enableDebugOutput + ? Common::path::deriveDebugBasePath(outputPath) + : std::string{}; + }; + hooks.execute = [this](const FillStatesCLIOptions& runnerOptions, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugBase) { + Pipeline::Metrics::Shared::MetricRunResult result; + result.metricResults.push_back( + executeForImage(runnerOptions, inputPath, outputPath, debugBase)); + return result; + }; + hooks.logResults = [](const FillStatesCLIOptions& runnerOptions, + const Pipeline::Metrics::Shared::MetricRunResult& result) { + std::cout << "\n[" << kLogTag + << "] Spatial normalization complete. Normalized image saved to " + << runnerOptions.outputPath << std::endl; + for (const auto& metric : result.metricResults) { + logMetricResult(metric, runnerOptions.includeSUVr); + } + }; + return Pipeline::Metrics::Shared::runSingle(options, fullCommand, hooks); +} + +std::string FillStatesService::buildMaskOutputPath(const std::string& outputPath) { + if (outputPath.empty()) { + return {}; + } + return Common::path::addSuffix(outputPath, "_fill_states_map"); +} + +void FillStatesService::logMetricResult(const Metrics::MetricResult& result, bool includeSUVr) { + std::cout << "\n=== Refactor FillStates Results ===" << std::endl; + std::cout << "Metric: " << result.metricName << std::endl; + for (const auto& [tracer, value] : result.tracerValues) { + std::cout << " " << tracer << ": " << value << std::endl; + } + if (includeSUVr) { + std::cout << " SUVr: " << result.suvr << std::endl; + } +} + +std::shared_ptr createService(ServiceContainer& container) { + auto config = container.resolve(); + auto spatialService = container.resolve(); + auto fileService = container.resolve(); + return std::make_shared(config, spatialService, fileService); +} + +} // namespace Pipeline::Metrics::FillStates diff --git a/localizer/src/metrics/fillstates/FillStatesService.h b/localizer/src/metrics/fillstates/FillStatesService.h new file mode 100644 index 0000000..b0d7233 --- /dev/null +++ b/localizer/src/metrics/fillstates/FillStatesService.h @@ -0,0 +1,51 @@ +#pragma once + +#include "../../core/di/ServiceContainer.h" +#include "../../core/interfaces/IConfiguration.h" +#include "../../core/services/IFileService.h" +#include "../../core/services/ISpatialNormalizationService.h" +#include "../shared/MetricTypes.h" +#include +#include + +namespace Pipeline::Metrics::FillStates { + +struct FillStatesCLIOptions { + std::string inputPath; + std::string outputPath; + std::string configPath = "config.toml"; + std::string tracer; + bool includeSUVr = false; + bool skipRegistration = false; + bool useIterativeRigid = false; + bool useManualFOV = false; + bool enableDebugOutput = false; + bool batchMode = false; + std::string debugOutputBasePath; +}; + +class FillStatesService { +public: + FillStatesService(ConfigurationPtr config, + std::shared_ptr spatialService, + std::shared_ptr fileService); + + int run(FillStatesCLIOptions options, const std::string& fullCommand); + +private: + Metrics::MetricResult executeForImage(const FillStatesCLIOptions& options, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugBasePath) const; + int runSingle(const FillStatesCLIOptions& options, const std::string& fullCommand) const; + static std::string buildMaskOutputPath(const std::string& outputPath); + static void logMetricResult(const Metrics::MetricResult& result, bool includeSUVr); + + ConfigurationPtr config_; + std::shared_ptr spatialService_; + std::shared_ptr fileService_; +}; + +std::shared_ptr createService(ServiceContainer& container); + +} // namespace Pipeline::Metrics::FillStates diff --git a/localizer/src/metrics/list/MetricsCLI.cpp b/localizer/src/metrics/list/MetricsCLI.cpp new file mode 100644 index 0000000..f0fd8bb --- /dev/null +++ b/localizer/src/metrics/list/MetricsCLI.cpp @@ -0,0 +1,72 @@ +#include "MetricsCLI.h" + +#include "../ModuleCatalog.h" +#include +#include +#include +#include +#include +#include + +namespace Pipeline::Metrics::List { + +namespace { + +struct MetricsCLIOptions { +}; + +int runCommand(const MetricsCLIOptions& options); + +void addArguments(argparse::ArgumentParser& parser) { + (void)parser; +} + +class MetricsCLI : public IMetricCLI { +public: + std::string getSubcommandName() const override { + return "metrics"; + } + + std::string getDescription() const override { + return "List the registered metric modules"; + } + + void configureArguments(argparse::ArgumentParser& parser) override { + addArguments(parser); + } + + int execute(const argparse::ArgumentParser& parser, + const std::string& fullCommand) override { + (void)parser; + (void)fullCommand; + return runCommand(MetricsCLIOptions{}); + } +}; + +int runCommand(const MetricsCLIOptions& options) { + (void)options; + auto modules = listMetricNames(); + if (modules.empty()) { + std::cout << "No registered metrics available." << std::endl; + return EXIT_SUCCESS; + } + + std::sort(modules.begin(), modules.end()); + std::cout << "Available metrics:" << std::endl; + for (const auto& moduleName : modules) { + std::cout << " " << moduleName << std::endl; + } + return EXIT_SUCCESS; +} + +} // namespace + +MetricCLIPtr createCLI() { + return std::make_shared(); +} + +void registerModule(MetricRegistry& registry) { + registry.registerModule({"metrics", false, &createCLI}); +} + +} // namespace Pipeline::Metrics::List diff --git a/localizer/src/metrics/list/MetricsCLI.h b/localizer/src/metrics/list/MetricsCLI.h new file mode 100644 index 0000000..caf5bac --- /dev/null +++ b/localizer/src/metrics/list/MetricsCLI.h @@ -0,0 +1,11 @@ +#pragma once + +#include "../shared/IMetricCLI.h" +#include "../shared/MetricRegistry.h" + +namespace Pipeline::Metrics::List { + +MetricCLIPtr createCLI(); +void registerModule(MetricRegistry& registry); + +} // namespace Pipeline::Metrics::List diff --git a/localizer/src/metrics/shared/BatchLogging.cpp b/localizer/src/metrics/shared/BatchLogging.cpp new file mode 100644 index 0000000..3e6e3e9 --- /dev/null +++ b/localizer/src/metrics/shared/BatchLogging.cpp @@ -0,0 +1,123 @@ +#include "BatchLogging.h" + +#include "../../core/common/PathUtils.h" +#include +#include +#include +#include +#include + +namespace Pipeline::Metrics::Shared { + +namespace { + +std::string currentTimeString() { + auto now = std::chrono::system_clock::now(); + auto in_time_t = std::chrono::system_clock::to_time_t(now); + std::stringstream ss; + ss << std::put_time(std::localtime(&in_time_t), "%Y-%m-%d %H:%M:%S"); + return ss.str(); +} + +} // namespace + +BatchInfoContext openBatchInfo(const std::filesystem::path& outputDir, + const std::string& commandLine, + const std::string& version, + const std::string& configPath, + const std::filesystem::path& inputDir) { + BatchInfoContext ctx; + ctx.stream.open(outputDir / "batch_info.txt", std::ios::out | std::ios::trunc); + if (!ctx.stream.is_open()) { + return ctx; + } + + ctx.stream << "Software Version: " << version << '\n'; + ctx.stream << "Command: " << commandLine << '\n'; + ctx.stream << "Start Time: " << currentTimeString() << '\n'; + ctx.stream << "Config Path: " << configPath << '\n'; + ctx.stream << "Input Directory: " << Common::path::toUtf8(inputDir) << '\n'; + ctx.stream << "Output Directory: " << Common::path::toUtf8(outputDir) << '\n'; + ctx.stream.flush(); + return ctx; +} + +void appendSuccessEntry(BatchInfoContext& ctx, const std::string& label) { + if (!ctx.stream.is_open()) { + return; + } + + ctx.stream << "Succeeded: " << label << '\n'; + ctx.stream.flush(); +} + +void appendFailureEntry(BatchInfoContext& ctx, const std::string& label, const std::string& reason) { + if (!ctx.stream.is_open()) { + return; + } + + ctx.stream << "Failed: " << label << " - " << reason << '\n'; + ctx.stream.flush(); +} + +void finalizeBatchInfo(BatchInfoContext& ctx, const BatchSummary& summary) { + if (!ctx.stream.is_open()) { + return; + } + + ctx.stream << "End Time: " << currentTimeString() << '\n'; + ctx.stream << "Processed: " << summary.processed + << ", Succeeded: " << summary.succeeded + << ", Failed: " << summary.failed << '\n'; + ctx.stream.flush(); +} + +CsvContext openCsv(const std::filesystem::path& outputDir) { + CsvContext ctx; + ctx.outputPath = outputDir / "results.csv"; + ctx.document = + std::make_unique(std::string{}, rapidcsv::LabelParams(0, -1)); + return ctx; +} + +void appendCsvRows(CsvContext& ctx, + const std::string& filename, + const std::vector& results) { + if (!ctx.document || results.empty()) { + return; + } + + if (!ctx.headerWritten) { + std::set keys; + for (const auto& metric : results) { + for (const auto& pair : metric.tracerValues) { + keys.insert(pair.first); + } + } + ctx.tracerKeys.assign(keys.begin(), keys.end()); + size_t col = 0; + ctx.document->SetColumnName(col++, "Filename"); + ctx.document->SetColumnName(col++, "Metric"); + for (const auto& key : ctx.tracerKeys) { + ctx.document->SetColumnName(col++, key); + } + ctx.document->SetColumnName(col, "SUVr"); + ctx.headerWritten = true; + } + + for (const auto& metric : results) { + const size_t row = ctx.nextRowIndex++; + ctx.document->SetCell("Filename", row, filename); + ctx.document->SetCell("Metric", row, metric.metricName); + for (const auto& key : ctx.tracerKeys) { + auto it = metric.tracerValues.find(key); + if (it != metric.tracerValues.end()) { + ctx.document->SetCell(key, row, it->second); + } + } + ctx.document->SetCell("SUVr", row, metric.suvr); + } + ctx.document->Save(Common::path::legacyFileName(ctx.outputPath)); +} + +} // namespace Pipeline::Metrics::Shared diff --git a/localizer/src/metrics/shared/BatchLogging.h b/localizer/src/metrics/shared/BatchLogging.h new file mode 100644 index 0000000..dd77b04 --- /dev/null +++ b/localizer/src/metrics/shared/BatchLogging.h @@ -0,0 +1,47 @@ +#pragma once + +#include "MetricTypes.h" +#include +#include +#include +#include +#include +#include +#include + +namespace Pipeline::Metrics::Shared { + +struct BatchSummary { + size_t processed = 0; + size_t succeeded = 0; + size_t failed = 0; +}; + +struct CsvContext { + std::filesystem::path outputPath; + std::unique_ptr document; + bool headerWritten = false; + std::vector tracerKeys; + size_t nextRowIndex = 0; +}; + +struct BatchInfoContext { + std::ofstream stream; +}; + +BatchInfoContext openBatchInfo(const std::filesystem::path& outputDir, + const std::string& commandLine, + const std::string& version, + const std::string& configPath, + const std::filesystem::path& inputDir); + +void appendSuccessEntry(BatchInfoContext& ctx, const std::string& label); +void appendFailureEntry(BatchInfoContext& ctx, const std::string& label, const std::string& reason); +void finalizeBatchInfo(BatchInfoContext& ctx, const BatchSummary& summary); + +CsvContext openCsv(const std::filesystem::path& outputDir); +void appendCsvRows(CsvContext& ctx, + const std::string& filename, + const std::vector& results); + +} // namespace Pipeline::Metrics::Shared diff --git a/localizer/src/metrics/shared/BatchRunner.h b/localizer/src/metrics/shared/BatchRunner.h new file mode 100644 index 0000000..c2f04fd --- /dev/null +++ b/localizer/src/metrics/shared/BatchRunner.h @@ -0,0 +1,121 @@ +#pragma once + +#include "BatchLogging.h" +#include "MetricRunResult.h" +#include "../../core/common/Filesystem.h" +#include "../../core/common/PathUtils.h" +#include "../../core/config/Version.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Pipeline::Metrics::Shared { + +template +struct BatchRunnerHooks { + using Execute = std::function; + using DebugPathResolver = + std::function; + using ResultLogger = std::function; + + std::string logTag; + std::string batchOutputSuffix; + Execute execute; + DebugPathResolver resolveDebugBase; + ResultLogger logResults; +}; + +template +int runBatch(const Options& options, + const std::string& fullCommand, + const BatchRunnerHooks& hooks) { + const std::filesystem::path inputDir = Common::path::fromUtf8(options.inputPath); + const std::filesystem::path outputDir = Common::path::fromUtf8(options.outputPath); + + if (!std::filesystem::exists(inputDir) || !std::filesystem::is_directory(inputDir)) { + std::cerr << "[" << hooks.logTag << "] Input directory does not exist: " + << options.inputPath << std::endl; + return EXIT_FAILURE; + } + if (!Common::fs::ensureDirectory(outputDir)) { + std::cerr << "[" << hooks.logTag << "] Output path must be a directory: " + << options.outputPath << std::endl; + return EXIT_FAILURE; + } + if (!options.skipRegistration && !Common::fs::isDirectoryEmpty(outputDir)) { + std::cerr << "[" << hooks.logTag + << "] Output directory must be empty unless --skip-normalization is set." + << std::endl; + return EXIT_FAILURE; + } + + const bool hasBidsPattern = !options.bidsPattern.empty(); + std::vector files; + try { + files = Common::fs::collectInputNiftiFiles(inputDir, options.bidsPattern); + } catch (const std::regex_error& ex) { + std::cerr << "[" << hooks.logTag << "] Invalid --bids regex: " + << ex.what() << std::endl; + return EXIT_FAILURE; + } + if (files.empty()) { + std::cout << "[" << hooks.logTag << "] No " + << (hasBidsPattern ? "PET-BIDS NIfTI" : "NIfTI") + << " files found in " << Common::path::toUtf8(inputDir) << std::endl; + return EXIT_SUCCESS; + } + + std::cout << "[" << hooks.logTag << "] Starting batch processing of " << files.size() + << " files: " << fullCommand << std::endl; + + auto batchInfo = openBatchInfo( + outputDir, fullCommand, SOFTWARE_VERSION, options.configPath, inputDir); + auto csvCtx = openCsv(outputDir); + BatchSummary summary; + + for (const auto& inputFile : files) { + summary.processed++; + const std::string outputPath = + Common::fs::buildOutputPath(inputFile, outputDir, hooks.batchOutputSuffix); + const std::string inputPath = Common::path::toUtf8(inputFile); + const std::string fileLabel = Common::path::toUtf8(inputFile.filename()); + const std::string debugBase = hooks.resolveDebugBase + ? hooks.resolveDebugBase(options, outputPath) + : std::string{}; + + try { + MetricRunResult result = + hooks.execute(options, inputPath, outputPath, debugBase); + summary.succeeded++; + std::cout << "[" << hooks.logTag << "][batch] Processed " + << fileLabel << std::endl; + if (hooks.logResults) { + hooks.logResults(options, result); + } + appendSuccessEntry(batchInfo, fileLabel); + appendCsvRows(csvCtx, fileLabel, result.metricResults); + } catch (const std::exception& ex) { + summary.failed++; + std::cerr << "[" << hooks.logTag << "][batch] Failed " + << fileLabel << ": " << ex.what() << std::endl; + appendFailureEntry(batchInfo, fileLabel, ex.what()); + } + } + + finalizeBatchInfo(batchInfo, summary); + std::cout << "[" << hooks.logTag << "] Batch complete. Success: " + << summary.succeeded << ", Failed: " << summary.failed << std::endl; + + return summary.failed == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} + +} // namespace Pipeline::Metrics::Shared diff --git a/localizer/src/metrics/shared/DebugPathHelpers.h b/localizer/src/metrics/shared/DebugPathHelpers.h new file mode 100644 index 0000000..5d0d062 --- /dev/null +++ b/localizer/src/metrics/shared/DebugPathHelpers.h @@ -0,0 +1,18 @@ +#pragma once +#include + +#include "../../core/common/PathUtils.h" + +namespace Pipeline::Metrics::Shared { + +template +void configureDerivedDebugBasePath(Options& options) { + if (!options.enableDebugOutput || options.outputPath.empty()) { + options.debugOutputBasePath.clear(); + return; + } + options.debugOutputBasePath = Common::path::deriveDebugBasePath(options.outputPath); +} + +} // namespace Pipeline::Metrics::Shared + diff --git a/localizer/src/metrics/shared/IMetricCLI.h b/localizer/src/metrics/shared/IMetricCLI.h new file mode 100644 index 0000000..764bf22 --- /dev/null +++ b/localizer/src/metrics/shared/IMetricCLI.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include +#include + +namespace Pipeline::Metrics { + +class IMetricCLI { +public: + virtual ~IMetricCLI() = default; + + virtual std::string getSubcommandName() const = 0; + virtual std::string getDescription() const = 0; + + virtual void configureArguments(argparse::ArgumentParser& parser) = 0; + virtual int execute(const argparse::ArgumentParser& parser, const std::string& fullCommand) = 0; +}; + +using MetricCLIPtr = std::shared_ptr; + +} // namespace Pipeline::Metrics diff --git a/localizer/src/metrics/shared/MetricRegistry.cpp b/localizer/src/metrics/shared/MetricRegistry.cpp new file mode 100644 index 0000000..6928e8e --- /dev/null +++ b/localizer/src/metrics/shared/MetricRegistry.cpp @@ -0,0 +1,44 @@ +#include "MetricRegistry.h" + +#include +#include + +namespace Pipeline::Metrics { + +void MetricRegistry::registerModule(MetricModuleRegistration module) { + if (module.name.empty()) { + throw std::invalid_argument("Metric module name must be non-empty"); + } + if (!module.createCLI) { + throw std::invalid_argument("Metric module must provide a CLI factory"); + } + + for (const auto& existing : modules_) { + if (existing.name == module.name) { + throw std::invalid_argument("Metric module already registered: " + module.name); + } + } + + modules_.push_back(std::move(module)); +} + +std::vector MetricRegistry::createCLIModules() const { + std::vector modules; + modules.reserve(modules_.size()); + for (const auto& registration : modules_) { + modules.push_back(registration.createCLI()); + } + return modules; +} + +std::vector MetricRegistry::listMetricNames() const { + std::vector names; + for (const auto& registration : modules_) { + if (registration.includeInMetricList) { + names.push_back(registration.name); + } + } + return names; +} + +} // namespace Pipeline::Metrics diff --git a/localizer/src/metrics/shared/MetricRegistry.h b/localizer/src/metrics/shared/MetricRegistry.h new file mode 100644 index 0000000..788956d --- /dev/null +++ b/localizer/src/metrics/shared/MetricRegistry.h @@ -0,0 +1,26 @@ +#pragma once + +#include "IMetricCLI.h" +#include +#include +#include + +namespace Pipeline::Metrics { + +struct MetricModuleRegistration { + std::string name; + bool includeInMetricList = true; + std::function createCLI; +}; + +class MetricRegistry { +public: + void registerModule(MetricModuleRegistration module); + std::vector createCLIModules() const; + std::vector listMetricNames() const; + +private: + std::vector modules_; +}; + +} // namespace Pipeline::Metrics diff --git a/localizer/src/metrics/shared/MetricRunResult.h b/localizer/src/metrics/shared/MetricRunResult.h new file mode 100644 index 0000000..c2c4474 --- /dev/null +++ b/localizer/src/metrics/shared/MetricRunResult.h @@ -0,0 +1,12 @@ +#pragma once + +#include "MetricTypes.h" +#include + +namespace Pipeline::Metrics::Shared { + +struct MetricRunResult { + std::vector metricResults; +}; + +} // namespace Pipeline::Metrics::Shared diff --git a/localizer/src/metrics/shared/MetricTypes.h b/localizer/src/metrics/shared/MetricTypes.h new file mode 100644 index 0000000..753f0ea --- /dev/null +++ b/localizer/src/metrics/shared/MetricTypes.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include + +namespace Pipeline::Metrics { + +struct MetricResult { + std::string metricName; + double suvr = 0.0; + std::map tracerValues; + + void printResult() const { + std::cout << "Metric: " << metricName << std::endl; + std::cout << "SUVr: " << suvr << std::endl; + for (const auto& [tracer, value] : tracerValues) { + std::cout << tracer << ": " << value << std::endl; + } + std::cout << std::endl; + } +}; + +} // namespace Pipeline::Metrics diff --git a/localizer/src/metrics/shared/SingleRunner.h b/localizer/src/metrics/shared/SingleRunner.h new file mode 100644 index 0000000..98cf1ac --- /dev/null +++ b/localizer/src/metrics/shared/SingleRunner.h @@ -0,0 +1,53 @@ +#pragma once + +#include "MetricRunResult.h" +#include +#include +#include +#include +#include + +namespace Pipeline::Metrics::Shared { + +template +struct SingleRunnerHooks { + using Execute = std::function; + using DebugPathResolver = + std::function; + using ResultLogger = std::function; + + std::string logTag; + Execute execute; + DebugPathResolver resolveDebugBase; + ResultLogger logResults; +}; + +template +int runSingle(const Options& options, + const std::string& fullCommand, + const SingleRunnerHooks& hooks) { + const std::string debugBase = hooks.resolveDebugBase + ? hooks.resolveDebugBase(options, options.outputPath) + : std::string{}; + + std::cout << "[" << hooks.logTag << "] Starting processing: " << fullCommand << std::endl; + try { + MetricRunResult result = + hooks.execute(options, options.inputPath, options.outputPath, debugBase); + if (hooks.logResults) { + hooks.logResults(options, result); + } + } catch (const std::exception& ex) { + std::cerr << "[" << hooks.logTag << "] Processing failed: " << ex.what() << std::endl; + return EXIT_FAILURE; + } + + std::cout << "[" << hooks.logTag << "] Processing completed successfully." << std::endl; + return EXIT_SUCCESS; +} + +} // namespace Pipeline::Metrics::Shared diff --git a/localizer/src/metrics/suvr/SUVrCLI.cpp b/localizer/src/metrics/suvr/SUVrCLI.cpp new file mode 100644 index 0000000..0fa8ee7 --- /dev/null +++ b/localizer/src/metrics/suvr/SUVrCLI.cpp @@ -0,0 +1,96 @@ +#include "SUVrCLI.h" + +#include "../../core/di/Bootstrap.h" +#include "SUVrService.h" +#include +#include + +namespace Pipeline::Metrics::SUVr { + +namespace { + +void addBaseArguments(argparse::ArgumentParser &parser) { + parser.add_argument("--input").help("Input PET image path").required(); + parser.add_argument("--output") + .help("Output processed image path") + .required(); + parser.add_argument("--config") + .help("Configuration file path") + .default_value(std::string{"config.toml"}); + parser.add_argument("--debug") + .help("Enable debug mode") + .default_value(false) + .implicit_value(true); + parser.add_argument("--batch") + .help("Enable batch processing mode") + .default_value(false) + .implicit_value(true); + parser.add_argument("--bids") + .help("Treat --input as a PET-BIDS dataset root and process PET files matching this regex") + .default_value(std::string{}); +} + +void addSpatialNormalizationArguments(argparse::ArgumentParser &parser) { + parser.add_argument("-i", "--iterative") + .help("Use iterative rigid transformation") + .default_value(false) + .implicit_value(true); + parser.add_argument("-m", "--manual-fov") + .help("Use manual FOV placement") + .default_value(false) + .implicit_value(true); +} + +class SUVrCLI : public IMetricCLI { +public: + std::string getSubcommandName() const override { return "suvr"; } + + std::string getDescription() const override { + return "Custom SUVr calculation with user-defined regions"; + } + + void configureArguments(argparse::ArgumentParser &parser) override { + addBaseArguments(parser); + addSpatialNormalizationArguments(parser); + parser.add_argument("--skip-normalization") + .help("Skip the spatial normalization stage") + .default_value(false) + .implicit_value(true); + parser.add_argument("--voi-mask") + .help("Absolute path to the VOI mask") + .required(); + parser.add_argument("--ref-mask") + .help("Absolute path to the reference mask") + .required(); + } + + int execute(const argparse::ArgumentParser &parser, + const std::string &fullCommand) override { + SUVrCLIOptions options; + options.inputPath = parser.get("--input"); + options.outputPath = parser.get("--output"); + options.configPath = parser.get("--config"); + options.enableDebugOutput = parser.get("--debug"); + options.batchMode = parser.get("--batch"); + options.bidsPattern = parser.get("--bids"); + options.skipRegistration = parser.get("--skip-normalization"); + options.useIterativeRigid = parser.get("--iterative"); + options.useManualFOV = parser.get("--manual-fov"); + options.voiMaskPath = parser.get("--voi-mask"); + options.refMaskPath = parser.get("--ref-mask"); + + BootstrapOptions bootstrapOptions; + bootstrapOptions.configPath = options.configPath; + bootstrapOptions.enableConfigDebug = options.enableDebugOutput; + bootstrapOptions.logTag = "suvr"; + auto container = buildCoreContainer(bootstrapOptions); + auto service = createService(*container); + return service->run(options, fullCommand); + } +}; + +} // namespace + +MetricCLIPtr createCLI() { return std::make_shared(); } + +} // namespace Pipeline::Metrics::SUVr diff --git a/localizer/src/metrics/suvr/SUVrCLI.h b/localizer/src/metrics/suvr/SUVrCLI.h new file mode 100644 index 0000000..6057e47 --- /dev/null +++ b/localizer/src/metrics/suvr/SUVrCLI.h @@ -0,0 +1,9 @@ +#pragma once + +#include "../shared/IMetricCLI.h" + +namespace Pipeline::Metrics::SUVr { + +MetricCLIPtr createCLI(); + +} // namespace Pipeline::Metrics::SUVr diff --git a/localizer/src/metrics/suvr/SUVrCalculator.cpp b/localizer/src/metrics/suvr/SUVrCalculator.cpp new file mode 100644 index 0000000..4b80a9d --- /dev/null +++ b/localizer/src/metrics/suvr/SUVrCalculator.cpp @@ -0,0 +1,36 @@ +#include "SUVrCalculator.h" + +#include "../../core/common/Common.h" +#include +#include + +Pipeline::Metrics::MetricResult SUVrCalculator::calculate(const Input& input) const { + if (!input.spatiallyNormalizedImage) { + throw std::invalid_argument("SUVrCalculator requires a spatially normalized image"); + } + if (input.voiMaskPath.empty() || input.refMaskPath.empty()) { + throw std::invalid_argument("SUVrCalculator requires VOI and reference masks"); + } + + const double suvr = calculateSUVr( + input.spatiallyNormalizedImage, input.voiMaskPath, input.refMaskPath); + + Pipeline::Metrics::MetricResult result; + result.metricName = "SUVr"; + result.suvr = suvr; + result.tracerValues["SUVr"] = static_cast(suvr); + return result; +} + +double SUVrCalculator::calculateSUVr(ImageType::Pointer spatialNormalizedImage, + const std::string& voiMaskPath, + const std::string& refMaskPath) { + ImageType::Pointer voiTemplate = Common::nifti::loadImage(voiMaskPath); + ImageType::Pointer refTemplate = Common::nifti::loadImage(refMaskPath); + ImageType::Pointer resampledImage = Common::image::resampleToMatch(voiTemplate, spatialNormalizedImage); + + double meanVoi = Common::image::calculateMeanInMask(resampledImage, voiTemplate); + double meanRef = Common::image::calculateMeanInMask(resampledImage, refTemplate); + + return meanVoi / meanRef; +} diff --git a/localizer/src/metrics/suvr/SUVrCalculator.h b/localizer/src/metrics/suvr/SUVrCalculator.h new file mode 100644 index 0000000..33797a6 --- /dev/null +++ b/localizer/src/metrics/suvr/SUVrCalculator.h @@ -0,0 +1,20 @@ +#pragma once + +#include "../../core/common/ImageTypes.h" +#include "../shared/MetricTypes.h" +#include +#include + +class SUVrCalculator { +public: + struct Input { + ImageType::Pointer spatiallyNormalizedImage; + std::string voiMaskPath; + std::string refMaskPath; + }; + + Pipeline::Metrics::MetricResult calculate(const Input& input) const; + static double calculateSUVr(ImageType::Pointer spatialNormalizedImage, + const std::string& voiMaskPath, + const std::string& refMaskPath); +}; diff --git a/localizer/src/metrics/suvr/SUVrModule.cpp b/localizer/src/metrics/suvr/SUVrModule.cpp new file mode 100644 index 0000000..601dbbd --- /dev/null +++ b/localizer/src/metrics/suvr/SUVrModule.cpp @@ -0,0 +1,11 @@ +#include "SUVrModule.h" + +#include "SUVrCLI.h" + +namespace Pipeline::Metrics::SUVr { + +void registerModule(MetricRegistry& registry) { + registry.registerModule({"suvr", true, &createCLI}); +} + +} // namespace Pipeline::Metrics::SUVr diff --git a/localizer/src/metrics/suvr/SUVrModule.h b/localizer/src/metrics/suvr/SUVrModule.h new file mode 100644 index 0000000..f1fa088 --- /dev/null +++ b/localizer/src/metrics/suvr/SUVrModule.h @@ -0,0 +1,9 @@ +#pragma once + +#include "../shared/MetricRegistry.h" + +namespace Pipeline::Metrics::SUVr { + +void registerModule(MetricRegistry& registry); + +} // namespace Pipeline::Metrics::SUVr diff --git a/localizer/src/metrics/suvr/SUVrService.cpp b/localizer/src/metrics/suvr/SUVrService.cpp new file mode 100644 index 0000000..860f0a8 --- /dev/null +++ b/localizer/src/metrics/suvr/SUVrService.cpp @@ -0,0 +1,146 @@ +#include "SUVrService.h" + +#include "../../core/common/Filesystem.h" +#include "../../core/common/NormalizationContracts.h" +#include "../../metrics/shared/BatchRunner.h" +#include "../../metrics/shared/DebugPathHelpers.h" +#include "../../metrics/shared/MetricRunResult.h" +#include "../../metrics/shared/SingleRunner.h" +#include "SUVrCalculator.h" +#include +#include +#include + +namespace Pipeline::Metrics::SUVr { + +namespace { + +constexpr const char* kLogTag = "suvr"; +constexpr const char* kBatchOutputSuffix = "_suvr.nii"; + +SpatialNormalizationRequest buildNormalizationRequest(const SUVrCLIOptions& options, + const std::string& inputPath, + const std::string& debugBasePath) { + SpatialNormalizationRequest request; + request.inputPath = inputPath; + request.skip = options.skipRegistration; + request.options.useIterativeRigid = options.useIterativeRigid; + request.options.useManualFOV = options.useManualFOV; + request.options.enableDebugOutput = options.enableDebugOutput; + request.options.debugOutputBasePath = options.enableDebugOutput ? debugBasePath : std::string{}; + return request; +} + +} // namespace + +SUVrService::SUVrService(std::shared_ptr spatialService, + std::shared_ptr fileService) + : spatialService_(std::move(spatialService)), + fileService_(std::move(fileService)) { + if (!spatialService_ || !fileService_) { + throw std::invalid_argument("SUVrService requires normalization and file services"); + } +} + +int SUVrService::run(SUVrCLIOptions options, const std::string& fullCommand) { + if (options.voiMaskPath.empty() || options.refMaskPath.empty()) { + std::cerr << "[suvr] Both --voi-mask and --ref-mask must be provided." << std::endl; + return EXIT_FAILURE; + } + + Pipeline::Metrics::Shared::configureDerivedDebugBasePath(options); + if (options.batchMode || !options.bidsPattern.empty()) { + return runBatch(options, fullCommand); + } + return runSingle(options, fullCommand); +} + +Metrics::MetricResult SUVrService::executeForImage(const SUVrCLIOptions& options, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugBasePath) const { + auto normalizationOutput = + spatialService_->normalize(buildNormalizationRequest(options, inputPath, debugBasePath)); + fileService_->saveNormalizedImage({normalizationOutput.spatiallyNormalizedImage, outputPath}); + + SUVrCalculator::Input input; + input.spatiallyNormalizedImage = normalizationOutput.spatiallyNormalizedImage; + input.voiMaskPath = options.voiMaskPath; + input.refMaskPath = options.refMaskPath; + return SUVrCalculator().calculate(input); +} + +int SUVrService::runSingle(const SUVrCLIOptions& options, const std::string& fullCommand) const { + if (!Common::fs::ensureParentDirectory(options.outputPath)) { + std::cerr << "[" << kLogTag << "] Failed to prepare output directory: " + << options.outputPath << std::endl; + return EXIT_FAILURE; + } + + Pipeline::Metrics::Shared::SingleRunnerHooks hooks; + hooks.logTag = kLogTag; + hooks.resolveDebugBase = [](const SUVrCLIOptions& runnerOptions, const std::string& outputPath) { + return runnerOptions.enableDebugOutput + ? Common::path::deriveDebugBasePath(outputPath) + : std::string{}; + }; + hooks.execute = [this](const SUVrCLIOptions& runnerOptions, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugBase) { + Pipeline::Metrics::Shared::MetricRunResult result; + result.metricResults.push_back( + executeForImage(runnerOptions, inputPath, outputPath, debugBase)); + return result; + }; + hooks.logResults = [](const SUVrCLIOptions& runnerOptions, + const Pipeline::Metrics::Shared::MetricRunResult& result) { + std::cout << "\n[" << kLogTag + << "] Spatial normalization complete. Normalized image saved to " + << runnerOptions.outputPath << std::endl; + for (const auto& metric : result.metricResults) { + logMetricResult(metric); + } + }; + return Pipeline::Metrics::Shared::runSingle(options, fullCommand, hooks); +} + +int SUVrService::runBatch(const SUVrCLIOptions& options, const std::string& fullCommand) const { + Pipeline::Metrics::Shared::BatchRunnerHooks hooks; + hooks.logTag = kLogTag; + hooks.batchOutputSuffix = kBatchOutputSuffix; + hooks.resolveDebugBase = [](const SUVrCLIOptions& runnerOptions, const std::string& outputPath) { + return runnerOptions.enableDebugOutput + ? Common::path::deriveDebugBasePath(outputPath) + : std::string{}; + }; + hooks.execute = [this](const SUVrCLIOptions& runnerOptions, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugBase) { + Pipeline::Metrics::Shared::MetricRunResult result; + result.metricResults.push_back( + executeForImage(runnerOptions, inputPath, outputPath, debugBase)); + return result; + }; + hooks.logResults = [](const SUVrCLIOptions&, + const Pipeline::Metrics::Shared::MetricRunResult& result) { + for (const auto& metric : result.metricResults) { + logMetricResult(metric); + } + }; + return Pipeline::Metrics::Shared::runBatch(options, fullCommand, hooks); +} + +void SUVrService::logMetricResult(const Metrics::MetricResult& result) { + std::cout << "\n=== SUVr Results ===" << std::endl; + std::cout << result.metricName << ": " << result.suvr << std::endl; +} + +std::shared_ptr createService(ServiceContainer& container) { + auto spatialService = container.resolve(); + auto fileService = container.resolve(); + return std::make_shared(spatialService, fileService); +} + +} // namespace Pipeline::Metrics::SUVr diff --git a/localizer/src/metrics/suvr/SUVrService.h b/localizer/src/metrics/suvr/SUVrService.h new file mode 100644 index 0000000..f73e8e7 --- /dev/null +++ b/localizer/src/metrics/suvr/SUVrService.h @@ -0,0 +1,49 @@ +#pragma once + +#include "../../core/di/ServiceContainer.h" +#include "../../core/services/IFileService.h" +#include "../../core/services/ISpatialNormalizationService.h" +#include "../shared/MetricTypes.h" +#include +#include + +namespace Pipeline::Metrics::SUVr { + +struct SUVrCLIOptions { + std::string inputPath; + std::string outputPath; + std::string configPath = "config.toml"; + bool enableDebugOutput = false; + std::string debugOutputBasePath; + bool batchMode = false; + std::string bidsPattern; + bool skipRegistration = false; + bool useIterativeRigid = false; + bool useManualFOV = false; + std::string voiMaskPath; + std::string refMaskPath; +}; + +class SUVrService { +public: + SUVrService(std::shared_ptr spatialService, + std::shared_ptr fileService); + + int run(SUVrCLIOptions options, const std::string& fullCommand); + +private: + Metrics::MetricResult executeForImage(const SUVrCLIOptions& options, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugBasePath) const; + int runSingle(const SUVrCLIOptions& options, const std::string& fullCommand) const; + int runBatch(const SUVrCLIOptions& options, const std::string& fullCommand) const; + static void logMetricResult(const Metrics::MetricResult& result); + + std::shared_ptr spatialService_; + std::shared_ptr fileService_; +}; + +std::shared_ptr createService(ServiceContainer& container); + +} // namespace Pipeline::Metrics::SUVr diff --git a/localizer/src/normalizers/RegistrationPipeline.cpp b/localizer/src/normalizers/RegistrationPipeline.cpp deleted file mode 100644 index 251e21b..0000000 --- a/localizer/src/normalizers/RegistrationPipeline.cpp +++ /dev/null @@ -1,33 +0,0 @@ -#include "RegistrationPipeline.h" - -RegistrationPipeline::RegistrationPipeline(const std::string& rigidModelPath, - const std::string& nonlinearModelPath) { - rigidEngine_ = std::make_unique(rigidModelPath); - nonlinearEngine_ = std::make_unique(nonlinearModelPath); -} - -ImageType::Pointer RegistrationPipeline::preprocess(ImageType::Pointer image) { - return ImagePreprocessor::preprocessForRigid(image); -} - -ImageType::Pointer RegistrationPipeline::preprocessVoxelMorph(ImageType::Pointer image) { - return ImagePreprocessor::preprocessForVoxelMorph(image); -} - -std::unordered_map> RegistrationPipeline::predict( - std::vector inputTensor, const std::vector inputShape) { - return rigidEngine_->predict(inputTensor, inputShape); -} - -std::unordered_map> RegistrationPipeline::predictVoxelMorph( - std::vector originalImg, std::vector movingImg, - std::vector templateImg) { - return nonlinearEngine_->predict(originalImg, movingImg, templateImg); -} - -std::tuple -RegistrationPipeline::getNewOriginAndDirection( - ImageType::Pointer preprocessedImage, ImageType::Pointer originalImage, - std::vector ac, std::vector pa, std::vector is) { - return rigidEngine_->getNewOriginAndDirection(preprocessedImage, originalImage, ac, pa, is); -} diff --git a/localizer/src/normalizers/RegistrationPipeline.h b/localizer/src/normalizers/RegistrationPipeline.h deleted file mode 100644 index ef28206..0000000 --- a/localizer/src/normalizers/RegistrationPipeline.h +++ /dev/null @@ -1,37 +0,0 @@ -#pragma once -#include "../utils/common.h" -#include "../preprocessing/ImagePreprocessor.h" -#include "RigidRegistrationEngine.h" -#include "NonlinearRegistrationEngine.h" -#include -#include - -/** - * @brief Complete registration pipeline combining rigid and nonlinear registration - * Replaces the old "Rigid" class with better separation of concerns - */ -class RegistrationPipeline { -public: - RegistrationPipeline(const std::string& rigidModelPath, const std::string& nonlinearModelPath); - ~RegistrationPipeline() = default; - - // Main interface methods (compatible with old Rigid class interface) - ImageType::Pointer preprocess(ImageType::Pointer image); - ImageType::Pointer preprocessVoxelMorph(ImageType::Pointer image); - - std::unordered_map> predict( - std::vector inputTensor, const std::vector inputShape); - - std::unordered_map> predictVoxelMorph( - std::vector originalImg, std::vector movingImg, - std::vector templateImg); - - std::tuple - getNewOriginAndDirection( - ImageType::Pointer preprocessedImage, ImageType::Pointer originalImage, - std::vector ac, std::vector pa, std::vector is); - -private: - std::unique_ptr rigidEngine_; - std::unique_ptr nonlinearEngine_; -}; diff --git a/localizer/src/normalizers/RigidVoxelMorphNormalizer.cpp b/localizer/src/normalizers/RigidVoxelMorphNormalizer.cpp deleted file mode 100644 index 307f796..0000000 --- a/localizer/src/normalizers/RigidVoxelMorphNormalizer.cpp +++ /dev/null @@ -1,218 +0,0 @@ -#include "RigidVoxelMorphNormalizer.h" -#include "../utils/common.h" -#include - -RigidVoxelMorphNormalizer::RigidVoxelMorphNormalizer(ConfigurationPtr config) - : config_(config) { - initializeModel(); -} - -void RigidVoxelMorphNormalizer::initializeModel() { - std::string rigidModelPath = config_->getModelPath("rigid"); - std::string voxelMorphPath = config_->getModelPath("affine_voxelmorph"); - std::string templatePath = config_->getTemplatePath("padded"); - - registrationPipeline_ = std::make_unique(rigidModelPath, voxelMorphPath); - paddedTemplate_ = Common::LoadNii(templatePath); -} - -ImageType::Pointer RigidVoxelMorphNormalizer::normalize(ImageType::Pointer inputImage) { - // Standard spatial normalization pipeline - ImageType::Pointer rigidImage = performRigidAlignment(inputImage); - saveDebugImage(rigidImage, "rigid"); - return performVoxelMorphWarping(rigidImage); -} - -ImageType::Pointer RigidVoxelMorphNormalizer::normalizeIterative( - ImageType::Pointer inputImage, int maxIter, float threshold) { - - ImageType::Pointer currentImage = performRigidAlignment(inputImage, false); - saveDebugImage(currentImage, "rigid0"); - ImageType::PointType lastOrigin = currentImage->GetOrigin(); - - for (int i = 0; i < maxIter; ++i) { - // Save temporary file - std::string tempPath = config_->getTempDirPath() + "/rigid_iter.nii"; - Common::SaveImage(currentImage, tempPath); - - // Execute next rigid registration - currentImage = performRigidAlignment(currentImage, true); - saveDebugImage(currentImage, "rigid" + std::to_string(i + 1)); - - // Check convergence - float originShift = 0; - for (int j = 0; j < 3; ++j) { - originShift += std::pow(currentImage->GetOrigin()[j] - lastOrigin[j], 2); - } - originShift = std::sqrt(originShift); - - if (originShift < threshold) { - break; - } - lastOrigin = currentImage->GetOrigin(); - } - - return performVoxelMorphWarping(currentImage); -} - -ImageType::Pointer RigidVoxelMorphNormalizer::normalizeManualFOV(ImageType::Pointer inputImage) { - // Skip rigid registration, perform nonlinear registration directly - return performVoxelMorphWarping(inputImage); -} - -RigidVoxelMorphNormalizer::NormalizationResult RigidVoxelMorphNormalizer::normalizeWithIntermediateResults(ImageType::Pointer inputImage) { - NormalizationResult result; - - // Perform rigid alignment - result.rigidAlignedImage = performRigidAlignment(inputImage); - saveDebugImage(result.rigidAlignedImage, "rigid"); - - // Perform VoxelMorph warping - result.spatiallyNormalizedImage = performVoxelMorphWarping(result.rigidAlignedImage); - - return result; -} - -RigidVoxelMorphNormalizer::NormalizationResult RigidVoxelMorphNormalizer::normalizeIterativeWithIntermediateResults(ImageType::Pointer inputImage, int maxIter, float threshold) { - NormalizationResult result; - - ImageType::Pointer currentImage = performRigidAlignment(inputImage, false); - saveDebugImage(currentImage, "rigid0"); - ImageType::PointType lastOrigin = currentImage->GetOrigin(); - - for (int i = 0; i < maxIter; ++i) { - // Save temporary file - std::string tempPath = config_->getTempDirPath() + "/rigid_iter.nii"; - Common::SaveImage(currentImage, tempPath); - - // Execute next rigid registration - currentImage = performRigidAlignment(currentImage, true); - saveDebugImage(currentImage, "rigid" + std::to_string(i + 1)); - - // Check convergence - float originShift = 0; - for (int j = 0; j < 3; ++j) { - originShift += std::pow(currentImage->GetOrigin()[j] - lastOrigin[j], 2); - } - originShift = std::sqrt(originShift); - - if (originShift < threshold) { - break; - } - lastOrigin = currentImage->GetOrigin(); - } - - result.rigidAlignedImage = currentImage; - result.spatiallyNormalizedImage = performVoxelMorphWarping(currentImage); - - return result; -} - -ImageType::Pointer RigidVoxelMorphNormalizer::performRigidAlignment(ImageType::Pointer inputImage, bool resampleFirst) { - ImageType::Pointer processedImage = inputImage; - - if (resampleFirst) { - processedImage = Common::ResampleToMatch(paddedTemplate_, processedImage); - } - - processedImage = registrationPipeline_->preprocess(processedImage); - saveDebugImage(processedImage, "rigid_preprocessed"); - - // Extract image data - std::vector imageData; - Common::ExtractImageData(processedImage, imageData); - - // Execute prediction - auto orientation = registrationPipeline_->predict(imageData, {1, 1, 64, 64, 64}); - - // Get new origin and direction - ImageType::Pointer originalImage = inputImage; // Use original image to get new origin and direction - auto newOriginAndDirection = registrationPipeline_->getNewOriginAndDirection( - processedImage, originalImage, - orientation["ac"], orientation["nose"], orientation["top"]); - - ImageType::PointType newOrigin = std::get<0>(newOriginAndDirection); - ImageType::DirectionType newDirection = std::get<1>(newOriginAndDirection); - - originalImage->SetDirection(newDirection); - originalImage->SetOrigin(newOrigin); - - return originalImage; -} - -ImageType::Pointer RigidVoxelMorphNormalizer::performVoxelMorphWarping(ImageType::Pointer rigidImage) { - // Resample to template space - ImageType::Pointer paddedImage = Common::ResampleToMatch(paddedTemplate_, rigidImage); - - // Preprocessing - paddedImage = registrationPipeline_->preprocessVoxelMorph(paddedImage); - saveDebugImage(paddedImage, "elastic_preprocessed"); - - // Prepare data - std::vector paddedImageData, paddedTemplateData, paddedOriginalData; - Common::ExtractImageData(paddedImage, paddedImageData); - Common::ExtractImageData(paddedTemplate_, paddedTemplateData); - - // Load original resampled image - ImageType::Pointer paddedOriginalImage = Common::ResampleToMatch(paddedTemplate_, rigidImage); - Common::ExtractImageData(paddedOriginalImage, paddedOriginalData); - - // Execute VoxelMorph prediction - auto warpedImageData = registrationPipeline_->predictVoxelMorph( - paddedOriginalData, paddedImageData, paddedTemplateData); - - // Create output image - ImageType::Pointer warpedImage = Common::CreateImageFromVector( - warpedImageData["warped"], paddedImage->GetLargestPossibleRegion().GetSize()); - - warpedImage->SetDirection(paddedTemplate_->GetDirection()); - warpedImage->SetOrigin(paddedTemplate_->GetOrigin()); - warpedImage->SetSpacing(paddedTemplate_->GetSpacing()); - - // Crop to MNI space - return cropMNI(warpedImage); -} - -ImageType::Pointer RigidVoxelMorphNormalizer::cropMNI(ImageType::Pointer image) { - ImageType::RegionType cropRegion; - ImageType::RegionType::IndexType start; - start[0] = config_->getInt("processing.crop_mni.start_x", 8); - start[1] = config_->getInt("processing.crop_mni.start_y", 16); - start[2] = config_->getInt("processing.crop_mni.start_z", 8); - - ImageType::RegionType::SizeType size; - size[0] = config_->getInt("processing.crop_mni.size_x", 79); - size[1] = config_->getInt("processing.crop_mni.size_y", 95); - size[2] = config_->getInt("processing.crop_mni.size_z", 79); - - cropRegion.SetSize(size); - cropRegion.SetIndex(start); - - using FilterType = itk::RegionOfInterestImageFilter; - FilterType::Pointer filter = FilterType::New(); - filter->SetRegionOfInterest(cropRegion); - filter->SetInput(image); - filter->Update(); - - return filter->GetOutput(); -} - -std::string RigidVoxelMorphNormalizer::getName() const { - return "RigidVoxelMorph"; -} - -bool RigidVoxelMorphNormalizer::isSupported(const std::string& modality) const { - // Support all modalities - return true; -} - -void RigidVoxelMorphNormalizer::setDebugMode(bool enable, const std::string& basePath) { - debugMode_ = enable; - debugBasePath_ = basePath; -} - -void RigidVoxelMorphNormalizer::saveDebugImage(ImageType::Pointer image, const std::string& suffix) { - if (!debugMode_ || debugBasePath_.empty()) return; - std::string filename = debugBasePath_ + "_" + suffix + ".nii"; - Common::SaveImage(image, filename); -} diff --git a/localizer/src/pipeline/BatchProcessor.cpp b/localizer/src/pipeline/BatchProcessor.cpp deleted file mode 100644 index c9b7fde..0000000 --- a/localizer/src/pipeline/BatchProcessor.cpp +++ /dev/null @@ -1,187 +0,0 @@ -#include "BatchProcessor.h" -#include "../utils/common.h" -#include -#include -#include -#include -#include -#include -#include - -namespace fs = std::filesystem; - -// Helper to get current time string -std::string getCurrentTime() { - auto now = std::chrono::system_clock::now(); - auto in_time_t = std::chrono::system_clock::to_time_t(now); - std::stringstream ss; - ss << std::put_time(std::localtime(&in_time_t), "%Y-%m-%d %H:%M:%S"); - return ss.str(); -} - -// Helper to check if string ends with suffix (C++17 compatible) -bool endsWith(const std::string& str, const std::string& suffix) { - if (str.length() < suffix.length()) { - return false; - } - return str.compare(str.length() - suffix.length(), suffix.length(), suffix) == 0; -} - -int BatchProcessor::runBatch( - const std::string& inputDir, - const std::string& outputDir, - const std::string& configPath, - const std::string& version, - const std::string& commandLine, - bool skipRegistration, - SingleFileProcessor processor -) { - // 1. Validate directories - if (!fs::exists(inputDir)) { - std::cerr << "Error: Input directory does not exist: " << inputDir << std::endl; - return EXIT_FAILURE; - } - if (!fs::is_directory(inputDir)) { - std::cerr << "Error: Input path is not a directory: " << inputDir << std::endl; - return EXIT_FAILURE; - } - - if (!fs::exists(outputDir)) { - try { - fs::create_directories(outputDir); - } catch (const std::exception& e) { - std::cerr << "Error: Could not create output directory: " << e.what() << std::endl; - return EXIT_FAILURE; - } - } else if (!fs::is_directory(outputDir)) { - std::cerr << "Error: Output path is not a directory: " << outputDir << std::endl; - return EXIT_FAILURE; - } - - // Check if output directory is empty when registration is not skipped - if (!skipRegistration && !fs::is_empty(outputDir)) { - std::cerr << "Error: Output directory must be empty when registration is enabled to avoid overwriting." << std::endl; - return EXIT_FAILURE; - } - - // 2. Scan files - std::vector inputFiles; - for (const auto& entry : fs::directory_iterator(inputDir)) { - if (entry.is_regular_file()) { - std::string ext = entry.path().extension().string(); - // Use manual check instead of ends_with for C++17 compatibility - std::string pathStr = entry.path().string(); - if (ext == ".nii" || endsWith(pathStr, ".nii.gz")) { - inputFiles.push_back(entry.path()); - } - } - } - - if (inputFiles.empty()) { - std::cerr << "Warning: No .nii or .nii.gz files found in " << inputDir << std::endl; - return EXIT_SUCCESS; - } - - std::cout << "Found " << inputFiles.size() << " files to process." << std::endl; - - // 3. Initialize batch info - std::string batchInfoPath = (fs::path(outputDir) / "batch_info.txt").string(); - std::ofstream batchInfo(batchInfoPath); - std::string startTime = getCurrentTime(); - - batchInfo << "Software Version: " << version << std::endl; - batchInfo << "Command: " << commandLine << std::endl; - batchInfo << "Start Time: " << startTime << std::endl; - batchInfo << "Config Path: " << configPath << std::endl; - batchInfo << "Input Directory: " << inputDir << std::endl; - batchInfo << "Output Directory: " << outputDir << std::endl; - batchInfo.flush(); - - // 4. Process files - std::string csvPath = (fs::path(outputDir) / "results.csv").string(); - std::ofstream csvFile(csvPath); - bool csvHeaderWritten = false; - std::vector tracerKeys; - - int successCount = 0; - int failCount = 0; - - for (const auto& inputFile : inputFiles) { - std::cout << "Processing [" << (successCount + failCount + 1) << "/" << inputFiles.size() << "]: " << inputFile.filename() << std::endl; - - std::string inputPath = inputFile.string(); - - // Construct output image path (preserve filename but ensure .nii extension if needed) - std::string outputFilename = inputFile.stem().string(); - // If .nii.gz, stem() removes .gz, getting .nii is tricky with std::filesystem sometimes if double extension - // But simple stem() is usually fine. If filename is "image.nii", stem is "image". - // If "image.nii.gz", stem is "image.nii". - if (endsWith(inputFile.string(), ".nii.gz")) { - outputFilename = inputFile.stem().stem().string(); // Remove .gz then .nii - } else if (inputFile.extension() == ".nii") { - outputFilename = inputFile.stem().string(); - } - - std::string outputImagePath = (fs::path(outputDir) / (outputFilename + "_processed.nii")).string(); - - try { - ProcessingResult result = processor(inputPath, outputImagePath); - - // Write CSV header if needed - if (!csvHeaderWritten && !result.metricResults.empty()) { - csvFile << "Filename,Metric"; - - // Collect all unique tracer keys from the first result - std::set keys; - for (const auto& mr : result.metricResults) { - for (const auto& pair : mr.tracerValues) { - keys.insert(pair.first); - } - } - tracerKeys.assign(keys.begin(), keys.end()); - - for (const auto& key : tracerKeys) { - csvFile << "," << key; - } - csvFile << ",SUVr"; // SUVr is standard - csvFile << "\n"; - csvHeaderWritten = true; - } - - // Write CSV row(s) - for (const auto& mr : result.metricResults) { - csvFile << inputFile.filename().string() << "," << mr.metricName; - - // Write tracer values in order of keys - for (const auto& key : tracerKeys) { - auto it = mr.tracerValues.find(key); - if (it != mr.tracerValues.end()) { - csvFile << "," << it->second; - } else { - csvFile << ","; // Empty if metric doesn't have this tracer - } - } - - csvFile << "," << mr.suvr << "\n"; - } - csvFile.flush(); - - successCount++; - } catch (const std::exception& e) { - std::cerr << "Failed to process " << inputFile.filename() << ": " << e.what() << std::endl; - batchInfo << "Failed: " << inputFile.filename() << " - " << e.what() << std::endl; - failCount++; - } - } - - // 5. Finalize batch info - std::string endTime = getCurrentTime(); - batchInfo << "End Time: " << endTime << std::endl; - batchInfo << "Processed: " << successCount << ", Failed: " << failCount << std::endl; - - std::cout << "\nBatch processing complete." << std::endl; - std::cout << "Success: " << successCount << ", Failed: " << failCount << std::endl; - std::cout << "Results saved to: " << outputDir << std::endl; - - return (failCount == 0) ? EXIT_SUCCESS : EXIT_FAILURE; -} diff --git a/localizer/src/pipeline/BatchProcessor.h b/localizer/src/pipeline/BatchProcessor.h deleted file mode 100644 index 161406c..0000000 --- a/localizer/src/pipeline/BatchProcessor.h +++ /dev/null @@ -1,34 +0,0 @@ -#pragma once -#include "ProcessingPipeline.h" -#include -#include - -class BatchProcessor { -public: - // Function signature for processing a single file - // Returns ProcessingResult which contains the metrics to be logged - using SingleFileProcessor = std::function; - - /** - * @brief Run batch processing on a directory of NIfTI files - * - * @param inputDir Directory containing input .nii files - * @param outputDir Directory to save outputs - * @param configPath Path to configuration file used - * @param version Software version string - * @param commandLine Full command line instruction - * @param skipRegistration Whether registration is skipped (affects output dir validation) - * @param processor Callback function to process each file - * @return EXIT_SUCCESS or EXIT_FAILURE - */ - static int runBatch( - const std::string& inputDir, - const std::string& outputDir, - const std::string& configPath, - const std::string& version, - const std::string& commandLine, - bool skipRegistration, - SingleFileProcessor processor - ); -}; - diff --git a/localizer/src/pipeline/ProcessingPipeline.cpp b/localizer/src/pipeline/ProcessingPipeline.cpp deleted file mode 100644 index 9384611..0000000 --- a/localizer/src/pipeline/ProcessingPipeline.cpp +++ /dev/null @@ -1,249 +0,0 @@ -#include "ProcessingPipeline.h" -#include "../factories/SpatialNormalizerFactory.h" -#include "../factories/MetricCalculatorFactory.h" -#include "../normalizers/RigidVoxelMorphNormalizer.h" -#include "../calculators/FillStatesCalculator.h" -#include "../utils/common.h" -#include -#include -#include -#include - -ProcessingPipeline::ProcessingPipeline(ConfigurationPtr config) : config_(config) { - initializePipeline(); -} - -void ProcessingPipeline::initializePipeline() { - // Create spatial normalizer - spatialNormalizer_ = SpatialNormalizerFactory::createFromString("rigid_voxelmorph", config_); - - // Metric calculators will be created on-demand based on processing options -} - -ProcessingResult ProcessingPipeline::process(const std::string& inputPath, - const std::string& outputPath, - const ProcessingOptions& options) { - ProcessingResult result; - - try { - // 1. Spatial normalization - if (!options.skipRegistration) { - auto normalizationResult = performSpatialNormalization(inputPath, options); - result.spatiallyNormalizedImage = normalizationResult.spatiallyNormalizedImage; - result.rigidAlignedImage = normalizationResult.rigidAlignedImage; - } else { - ImageType::Pointer inputImage = loadAndValidateInput(inputPath); - result.spatiallyNormalizedImage = inputImage; - result.rigidAlignedImage = inputImage; // Use input image as both if skipping registration - } - - // Save spatial normalization result - saveResult(result.spatiallyNormalizedImage, outputPath); - - // 2. Calculate semi-quantitative metrics - if (!options.selectedMetric.empty()) { - ImageType::Pointer fillStatesMask; - result.metricResults = calculateMetrics(result.spatiallyNormalizedImage, options, fillStatesMask); - if (fillStatesMask) { - result.fillStatesMaskImage = fillStatesMask; - result.hasFillStatesMask = true; - } - } - - // 3. ADNI-style processing and decoupling (if needed) - if (options.enableADNIStyle || !options.decoupleModality.empty()) { - // Use the correctly rigid-aligned image to generate ADNI-style image - ImageType::Pointer adniStyleImage = prepareADNIStyleImage(result.rigidAlignedImage, result.spatiallyNormalizedImage); - - saveResult(adniStyleImage, outputPath); - - // Execute decoupling - if (!options.decoupleModality.empty()) { - result.decoupledResult = performDecoupling(adniStyleImage, options.decoupleModality); - result.hasDecoupledResult = true; - result.decoupledResult.SaveResults(outputPath); - } - } - - // 4. Save fill-states mask map if available - if (result.hasFillStatesMask && result.fillStatesMaskImage) { - std::string fsPath = Common::addSuffixToFilePath(outputPath, "_fill_states_map"); - saveResult(result.fillStatesMaskImage, fsPath); - } - - } catch (const std::exception& e) { - std::cerr << "Error occurred during processing: " << e.what() << std::endl; - throw; - } - - return result; -} - -SpatialNormalizationResult ProcessingPipeline::performSpatialNormalization(const std::string& inputPath, - const ProcessingOptions& options) { - ImageType::Pointer inputImage = loadAndValidateInput(inputPath); - SpatialNormalizationResult result; - - // Choose different normalization methods based on options - auto rigidVoxelMorphNormalizer = std::dynamic_pointer_cast(spatialNormalizer_); - - // Set debug options for normalizer - if (rigidVoxelMorphNormalizer && options.enableDebugOutput) { - rigidVoxelMorphNormalizer->setDebugMode(true, options.debugOutputBasePath); - } - - if (options.useManualFOV) { - // For manual FOV, use input image as rigid aligned (no rigid registration) - result.rigidAlignedImage = inputImage; - result.spatiallyNormalizedImage = rigidVoxelMorphNormalizer->normalizeManualFOV(inputImage); - } else if (options.useIterativeRigid) { - auto normResult = rigidVoxelMorphNormalizer->normalizeIterativeWithIntermediateResults(inputImage, - options.maxIterations, - options.convergenceThreshold); - result.rigidAlignedImage = normResult.rigidAlignedImage; - result.spatiallyNormalizedImage = normResult.spatiallyNormalizedImage; - } else { - auto normResult = rigidVoxelMorphNormalizer->normalizeWithIntermediateResults(inputImage); - result.rigidAlignedImage = normResult.rigidAlignedImage; - result.spatiallyNormalizedImage = normResult.spatiallyNormalizedImage; - } - - return result; -} - -std::vector ProcessingPipeline::calculateMetrics(ImageType::Pointer spatiallyNormalizedImage, - const ProcessingOptions& options, - ImageType::Pointer& fillStatesMaskOut) { - std::vector results; - - // Create selected metric calculator - auto calculators = MetricCalculatorFactory::createSelected(options.selectedMetric, config_); - - for (auto& calculator : calculators) { - try { - // Inject tracer information and capture fill-states mask for tracer-dependent metrics - const std::string metricLower = Common::toLower(options.selectedMetric); - if (metricLower == "fillstates") { - auto* fsCalc = dynamic_cast(calculator.get()); - if (fsCalc != nullptr) { - if (!options.selectedMetricTracer.empty()) { - fsCalc->setTracer(options.selectedMetricTracer); - } - } - } - - MetricResult result = calculator->calculate(spatiallyNormalizedImage); - - if (Common::toLower(options.selectedMetric) == "fillstates") { - auto* fsCalc = dynamic_cast(calculator.get()); - if (fsCalc != nullptr) { - fillStatesMaskOut = fsCalc->getLastMaskImage(); - } - } - - results.push_back(result); - } catch (const std::exception& e) { - std::cerr << "Error calculating metric " << calculator->getName() << ": " << e.what() << std::endl; - throw "Failed to calculate " + calculator->getName(); - } - } - - return results; -} - -DecoupledResult ProcessingPipeline::performDecoupling(ImageType::Pointer adniStyleImage, const std::string& modality) { - std::string modelPath; - std::string lowerModality = modality; - std::transform(lowerModality.begin(), lowerModality.end(), lowerModality.begin(), ::tolower); - - if (lowerModality == "abeta") { - modelPath = config_->getModelPath("abeta_decoupler"); - } else if (lowerModality == "tau") { - modelPath = config_->getModelPath("tau_decoupler"); - } else { - throw std::invalid_argument("Unsupported decoupling modality: " + modality); - } - - // Prefer ensemble if provided - std::vector modelPaths; - if (lowerModality == "abeta") { - modelPaths = config_->getModelPaths("abeta_decoupler"); - } else if (lowerModality == "tau") { - modelPaths = config_->getModelPaths("tau_decoupler"); - } - - DecoupledResult decoupled; - if (!modelPaths.empty()) { - Decoupler decoupler(modelPaths); - decoupled = decoupler.predict(adniStyleImage); - } else { - Decoupler decoupler(modelPath); - decoupled = decoupler.predict(adniStyleImage); - } - - // Compute ADAD per-tracer using config conversion (slope * x + intercept) - try { - std::string sectionName = "adad_" + lowerModality + ".tracers"; - auto sec = config_->getSection(sectionName); - // Aggregate slopes and intercepts by tracer name - std::map> coeffs; // tracer -> (slope, intercept) - for (const auto& kv : sec) { - const std::string& key = kv.first; // e.g., "pib.slope" - const std::string& val = kv.second; // string number - auto dot = key.find('.'); - if (dot == std::string::npos) continue; - std::string tracer = key.substr(0, dot); - std::string what = key.substr(dot + 1); - float fval = 0.0f; - try { fval = std::stof(val); } catch (...) { continue; } - auto &p = coeffs[tracer]; - if (what == "slope") p.first = fval; else if (what == "intercept") p.second = fval; - } - for (const auto& kv : coeffs) { - const std::string& tracer = kv.first; - float slope = kv.second.first; - float intercept = kv.second.second; - float converted = slope * static_cast(decoupled.ADADscore) + intercept; - decoupled.ADADTracerValues[tracer] = converted; - } - } catch (...) { - // Silently ignore config parsing errors for ADAD printing - } - - return decoupled; -} - -ImageType::Pointer ProcessingPipeline::prepareADNIStyleImage(ImageType::Pointer rigidImage, - ImageType::Pointer spatiallyNormalizedImage) { - // Load cerebellar gray matter mask - ImageType::Pointer refCerebralGray = Common::LoadNii(config_->getMaskPath("cerebral_gray")); - - // Resample spatially normalized image to mask space - ImageType::Pointer resampledImage = Common::ResampleToMatch(refCerebralGray, spatiallyNormalizedImage); - - // Calculate cerebellar gray matter mean value - double meanCerebralGray = Common::CalculateMeanInMask(resampledImage, refCerebralGray); - - // Load ADNI template - ImageType::Pointer adniTemplate = Common::LoadNii(config_->getTemplatePath("adni_pet_core")); - - // Resample rigid-aligned image to ADNI template space - ImageType::Pointer adniStyleImage = Common::ResampleToMatch(adniTemplate, rigidImage); - - // Perform intensity normalization - Common::DivideVoxelsByValue(adniStyleImage, meanCerebralGray); - - return adniStyleImage; -} - -ImageType::Pointer ProcessingPipeline::loadAndValidateInput(const std::string& inputPath) { - ImageType::Pointer image = Common::LoadNii(inputPath); - if (!image) { - throw std::runtime_error("Unable to load input image: " + inputPath); - } - return image; -} - -void ProcessingPipeline::saveResult(ImageType::Pointer image, const std::string& outputPath) { - Common::SaveImage(image, outputPath); -} diff --git a/localizer/src/pipeline/ProcessingPipeline.h b/localizer/src/pipeline/ProcessingPipeline.h deleted file mode 100644 index 00f2cea..0000000 --- a/localizer/src/pipeline/ProcessingPipeline.h +++ /dev/null @@ -1,103 +0,0 @@ -#pragma once -#include "../interfaces/ISpatialNormalizer.h" -#include "../interfaces/IMetricCalculator.h" -#include "../interfaces/IConfiguration.h" -#include "../decouplers/Decoupler.h" -#include - -/** - * @brief Spatial normalization result - */ -struct SpatialNormalizationResult { - ImageType::Pointer rigidAlignedImage; - ImageType::Pointer spatiallyNormalizedImage; -}; - -/** - * @brief Processing pipeline options - */ -struct ProcessingOptions { - bool skipRegistration = false; - bool useIterativeRigid = false; - bool useManualFOV = false; - bool enableADNIStyle = false; - std::string decoupleModality = ""; // "abeta", "tau" or empty - - // Iteration parameters - int maxIterations = 5; - float convergenceThreshold = 2.0f; - - // Debug parameters - bool enableDebugOutput = false; - std::string debugOutputBasePath = ""; - - // Metric selection parameters - std::string selectedMetric = ""; // "suvr", "centiloid", "centaur", "centaurz", "fillstates" - std::string selectedMetricTracer = ""; // "fbp", "fdg", "ftp" (for tracer-dependent metrics) -}; - -/** - * @brief Processing result - */ -struct ProcessingResult { - ImageType::Pointer spatiallyNormalizedImage; - ImageType::Pointer rigidAlignedImage; // Add rigid-aligned intermediate result - std::vector metricResults; - DecoupledResult decoupledResult; - bool hasDecoupledResult = false; - ImageType::Pointer fillStatesMaskImage; // Optional fill-states mask - bool hasFillStatesMask = false; - - void printAllResults() const { - for (const auto& result : metricResults) { - result.printResult(); - } - if (hasDecoupledResult) { - decoupledResult.printResult(); - } - } -}; - -/** - * @brief Main processing pipeline - */ -class ProcessingPipeline { -public: - explicit ProcessingPipeline(ConfigurationPtr config); - virtual ~ProcessingPipeline() = default; - - /** - * @brief Execute complete processing workflow - */ - ProcessingResult process(const std::string& inputPath, - const std::string& outputPath, - const ProcessingOptions& options = ProcessingOptions{}); - - /** - * @brief Execute spatial normalization only - */ - SpatialNormalizationResult performSpatialNormalization(const std::string& inputPath, - const ProcessingOptions& options); - - /** - * @brief Execute metric calculation only - */ - std::vector calculateMetrics(ImageType::Pointer spatiallyNormalizedImage, - const ProcessingOptions& options, - ImageType::Pointer& fillStatesMaskOut); - - /** - * @brief Execute decoupling analysis - */ - DecoupledResult performDecoupling(ImageType::Pointer adniStyleImage, const std::string& modality); - -private: - ConfigurationPtr config_; - SpatialNormalizerPtr spatialNormalizer_; - - void initializePipeline(); - ImageType::Pointer loadAndValidateInput(const std::string& inputPath); - void saveResult(ImageType::Pointer image, const std::string& outputPath); - ImageType::Pointer prepareADNIStyleImage(ImageType::Pointer rigidImage, ImageType::Pointer spatiallyNormalizedImage); -}; - diff --git a/localizer/src/cli/Options.cpp b/localizer/src/spatialNormalizations/CLIOptions.cpp similarity index 52% rename from localizer/src/cli/Options.cpp rename to localizer/src/spatialNormalizations/CLIOptions.cpp index 3b799ec..1c5b6cf 100644 --- a/localizer/src/cli/Options.cpp +++ b/localizer/src/spatialNormalizations/CLIOptions.cpp @@ -1,4 +1,6 @@ -#include "Options.h" +#include "CLIOptions.h" + +#include "../core/common/PathUtils.h" #include void addBaseArguments(argparse::ArgumentParser& parser) { @@ -10,7 +12,7 @@ void addBaseArguments(argparse::ArgumentParser& parser) { .required(); parser.add_argument("--config") .help("Configuration file path") - .default_value("config.toml"); + .default_value(std::string{"config.toml"}); parser.add_argument("--debug") .help("Enable debug mode") .default_value(false) @@ -19,6 +21,9 @@ void addBaseArguments(argparse::ArgumentParser& parser) { .help("Enable batch processing mode") .default_value(false) .implicit_value(true); + parser.add_argument("--bids") + .help("Treat --input as a PET-BIDS dataset root and process PET files matching this regex") + .default_value(std::string{}); } void addSpatialNormalizationArguments(argparse::ArgumentParser& parser) { @@ -32,34 +37,18 @@ void addSpatialNormalizationArguments(argparse::ArgumentParser& parser) { .implicit_value(true); } -void addSUVrDerivedMetricArguments(argparse::ArgumentParser& parser) { - addBaseArguments(parser); - addSpatialNormalizationArguments(parser); - - parser.add_argument("--suvr") - .help("Include SUVr values in the output") - .default_value(false) - .implicit_value(true); - parser.add_argument("--skip-normalization") - .help("Skip spatial normalization and calculate metrics directly") - .default_value(false) - .implicit_value(true); -} +void setupDebugOutput(BaseCommandOptions& options) { + if (!options.enableDebugOutput || options.outputPath.empty()) { + return; + } -void addFillStatesArguments(argparse::ArgumentParser& parser) { - addSUVrDerivedMetricArguments(parser); - parser.add_argument("--tracer") - .help("Tracer type to use for fill-states metric (fbp, fdg, ftp)") - .required() - .choices("fbp", "fdg", "ftp"); -} + std::filesystem::path outputFilePath = Common::path::fromUtf8(options.outputPath); + std::string baseName = Common::path::toUtf8(outputFilePath.stem()); + std::string directory = Common::path::toUtf8(outputFilePath.parent_path()); -void setupDebugOutput(BaseCommandOptions& options) { - if (options.enableDebugOutput && !options.outputPath.empty()) { - std::filesystem::path outputFilePath(options.outputPath); - std::string baseName = outputFilePath.stem().string(); - std::string directory = outputFilePath.parent_path().string(); + if (!directory.empty()) { options.debugOutputBasePath = directory + "/" + baseName; + } else { + options.debugOutputBasePath = baseName; } } - diff --git a/localizer/src/spatialNormalizations/CLIOptions.h b/localizer/src/spatialNormalizations/CLIOptions.h new file mode 100644 index 0000000..0fe4f9e --- /dev/null +++ b/localizer/src/spatialNormalizations/CLIOptions.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +struct BaseCommandOptions { + std::string inputPath; + std::string outputPath; + std::string configPath = "config.toml"; + bool enableDebugOutput = false; + std::string debugOutputBasePath; + bool batchMode = false; + std::string bidsPattern; +}; + +struct SpatialNormalizationOptions { + bool useIterativeRigid = false; + bool useManualFOV = false; +}; + +struct NormalizeCommandOptions : BaseCommandOptions, SpatialNormalizationOptions { + bool enableADNIStyle = false; + std::string normalizationMethod = "rigid_voxelmorph"; +}; + +void addBaseArguments(argparse::ArgumentParser& parser); +void addSpatialNormalizationArguments(argparse::ArgumentParser& parser); +void setupDebugOutput(BaseCommandOptions& options); + diff --git a/localizer/src/spatialNormalizations/ModuleCatalog.cpp b/localizer/src/spatialNormalizations/ModuleCatalog.cpp new file mode 100644 index 0000000..4bae63a --- /dev/null +++ b/localizer/src/spatialNormalizations/ModuleCatalog.cpp @@ -0,0 +1,18 @@ +#include "ModuleCatalog.h" +#include "standard/NormalizeCLI.h" +#include "adni/AdniPetCoreCLI.h" +#include "rigid/RigidCLI.h" + +namespace Pipeline::SpatialNormalization { + +std::vector buildCLIModules() { + std::vector modules; + modules.push_back(Standard::createCLI()); + modules.push_back(Adni::createCLI()); + modules.push_back(Rigid::createCLI()); + return modules; +} + +} // namespace Pipeline::SpatialNormalization + + diff --git a/localizer/src/spatialNormalizations/ModuleCatalog.h b/localizer/src/spatialNormalizations/ModuleCatalog.h new file mode 100644 index 0000000..f07c113 --- /dev/null +++ b/localizer/src/spatialNormalizations/ModuleCatalog.h @@ -0,0 +1,12 @@ +#pragma once +#include +#include "../core/interfaces/ISpatialNormalizationCLI.h" + +namespace Pipeline::SpatialNormalization { + +std::vector buildCLIModules(); + +} // namespace Pipeline::SpatialNormalization + + + diff --git a/localizer/src/spatialNormalizations/adni/AdniPetCoreCLI.cpp b/localizer/src/spatialNormalizations/adni/AdniPetCoreCLI.cpp new file mode 100644 index 0000000..f6fa6d1 --- /dev/null +++ b/localizer/src/spatialNormalizations/adni/AdniPetCoreCLI.cpp @@ -0,0 +1,249 @@ +#include "AdniPetCoreCLI.h" +#include "../CLIOptions.h" +#include "../../core/common/Filesystem.h" +#include "../../core/common/NormalizationContracts.h" +#include "../../core/common/PathUtils.h" +#include "../../core/config/Version.h" +#include "../../core/di/Bootstrap.h" +#include "../../core/services/IFileService.h" +#include "../../core/services/ISpatialNormalizationService.h" +#include "../../metrics/shared/BatchLogging.h" +#include +#include +#include +#include +#include +#include + +namespace Pipeline::SpatialNormalization::Adni { + +namespace { + +struct RunConfig { + bool enableAdniPetCore = true; + const char* logTag = "adni-pet-core"; +}; + +constexpr const char* kBatchOutputSuffix = "_ADNI_style.nii"; + +std::string resolveDebugBasePath(const NormalizeCommandOptions& options, const std::string& outputPath) { + if (!options.enableDebugOutput || outputPath.empty()) { + return {}; + } + + std::filesystem::path outputFilePath = Common::path::fromUtf8(outputPath); + std::string baseName = Common::path::toUtf8(outputFilePath.stem()); + std::string directory = Common::path::toUtf8(outputFilePath.parent_path()); + return directory.empty() ? baseName : directory + "/" + baseName; +} + +int processSingleImage(const NormalizeCommandOptions& options, + const RunConfig& config, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugOutputBasePath, + bool logCompletion) { + BootstrapOptions bootstrapOptions; + bootstrapOptions.configPath = options.configPath; + bootstrapOptions.enableConfigDebug = options.enableDebugOutput; + bootstrapOptions.logTag = config.logTag; + + auto container = Pipeline::buildCoreContainer(bootstrapOptions); + SpatialNormalizationRequest request; + request.inputPath = inputPath; + request.skip = false; + request.options.useIterativeRigid = options.useIterativeRigid; + request.options.useManualFOV = options.useManualFOV; + request.options.enableDebugOutput = options.enableDebugOutput; + request.options.debugOutputBasePath = debugOutputBasePath; + request.options.enableAdniPetCore = config.enableAdniPetCore; + auto spatialService = container->resolve(); + auto fileService = container->resolve(); + + try { + auto output = spatialService->normalize(request); + fileService->saveNormalizedImage({output.spatiallyNormalizedImage, outputPath}); + if (logCompletion) { + std::cout << "\n[" << config.logTag + << "] ADNI PET Core normalization complete. Output saved to " + << outputPath << std::endl; + } + } catch (const std::exception& ex) { + std::cerr << "[" << config.logTag << "] Processing failed: " << ex.what() << std::endl; + return EXIT_FAILURE; + } + + return EXIT_SUCCESS; +} + +int runSingleNormalization(const NormalizeCommandOptions& options, + const RunConfig& config, + const std::string& fullCommand) { + if (!Common::fs::ensureParentDirectory(options.outputPath)) { + std::cerr << "[" << config.logTag << "] Failed to prepare output directory: " + << options.outputPath << std::endl; + return EXIT_FAILURE; + } + + std::cout << "[" << config.logTag << "] Starting processing: " << fullCommand << std::endl; + const int result = processSingleImage( + options, + config, + options.inputPath, + options.outputPath, + options.debugOutputBasePath, + true); + if (result != EXIT_SUCCESS) { + return result; + } + + std::cout << "[" << config.logTag << "] Processing completed successfully." << std::endl; + return EXIT_SUCCESS; +} + +int runBatchNormalization(const NormalizeCommandOptions& options, + const RunConfig& config, + const std::string& fullCommand) { + const std::filesystem::path inputDir = Common::path::fromUtf8(options.inputPath); + const std::filesystem::path outputDir = Common::path::fromUtf8(options.outputPath); + + if (!std::filesystem::exists(inputDir) || !std::filesystem::is_directory(inputDir)) { + std::cerr << "[" << config.logTag << "] Input directory does not exist: " + << options.inputPath << std::endl; + return EXIT_FAILURE; + } + if (!Common::fs::ensureDirectory(outputDir)) { + std::cerr << "[" << config.logTag << "] Output path must be a directory: " + << options.outputPath << std::endl; + return EXIT_FAILURE; + } + if (!Common::fs::isDirectoryEmpty(outputDir)) { + std::cerr << "[" << config.logTag + << "] Output directory must be empty for batch ADNI PET Core processing." + << std::endl; + return EXIT_FAILURE; + } + + const bool hasBidsPattern = !options.bidsPattern.empty(); + std::vector files; + try { + files = Common::fs::collectInputNiftiFiles(inputDir, options.bidsPattern); + } catch (const std::regex_error& ex) { + std::cerr << "[" << config.logTag << "] Invalid --bids regex: " + << ex.what() << std::endl; + return EXIT_FAILURE; + } + if (files.empty()) { + std::cout << "[" << config.logTag << "] No " + << (hasBidsPattern ? "PET-BIDS NIfTI" : "NIfTI") + << " files found in " << Common::path::toUtf8(inputDir) << std::endl; + return EXIT_SUCCESS; + } + + std::cout << "[" << config.logTag << "] Starting batch processing of " << files.size() + << " files: " << fullCommand << std::endl; + + auto batchInfo = Pipeline::Metrics::Shared::openBatchInfo( + outputDir, fullCommand, SOFTWARE_VERSION, options.configPath, inputDir); + Pipeline::Metrics::Shared::BatchSummary summary; + + for (const auto& inputFile : files) { + summary.processed++; + const std::string outputPath = + Common::fs::buildOutputPath(inputFile, outputDir, kBatchOutputSuffix); + const std::string inputPath = Common::path::toUtf8(inputFile); + const std::string fileLabel = Common::path::toUtf8(inputFile.filename()); + const std::string debugBase = resolveDebugBasePath(options, outputPath); + + try { + const int result = processSingleImage( + options, + config, + inputPath, + outputPath, + debugBase, + false); + if (result != EXIT_SUCCESS) { + throw std::runtime_error("ADNI PET Core normalization failed."); + } + + summary.succeeded++; + std::cout << "[" << config.logTag << "][batch] Processed " + << fileLabel << std::endl; + Pipeline::Metrics::Shared::appendSuccessEntry( + batchInfo, fileLabel); + } catch (const std::exception& ex) { + summary.failed++; + std::cerr << "[" << config.logTag << "][batch] Failed " + << fileLabel << ": " << ex.what() << std::endl; + Pipeline::Metrics::Shared::appendFailureEntry( + batchInfo, fileLabel, ex.what()); + } + } + + Pipeline::Metrics::Shared::finalizeBatchInfo(batchInfo, summary); + std::cout << "[" << config.logTag << "] Batch complete. Success: " + << summary.succeeded << ", Failed: " << summary.failed << std::endl; + + return summary.failed == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} + +int runNormalization(const NormalizeCommandOptions& options, + const RunConfig& config, + const std::string& fullCommand) { + if (options.batchMode || !options.bidsPattern.empty()) { + return runBatchNormalization(options, config, fullCommand); + } + return runSingleNormalization(options, config, fullCommand); +} + +class AdniPetCoreCLI final : public ISpatialNormalizationCLI { +public: + std::string getSubcommandName() const override { + return "adni-pet-core"; + } + + std::string getDescription() const override { + return "Run ADNI PET Core compliant spatial normalization"; + } + + void configureArguments(argparse::ArgumentParser& parser) override { + addBaseArguments(parser); + addSpatialNormalizationArguments(parser); + + parser.add_argument("--method") + .help("Normalization method") + .default_value("rigid_voxelmorph"); + } + + int execute(const argparse::ArgumentParser& parser, const std::string& fullCommand) override { + NormalizeCommandOptions options; + options.inputPath = parser.get("--input"); + options.outputPath = parser.get("--output"); + options.configPath = parser.get("--config"); + options.normalizationMethod = parser.get("--method"); + options.useIterativeRigid = parser.get("--iterative"); + options.useManualFOV = parser.get("--manual-fov"); + options.enableDebugOutput = parser.get("--debug"); + options.batchMode = parser.get("--batch"); + options.bidsPattern = parser.get("--bids"); + options.enableADNIStyle = true; + + if (!options.batchMode && options.bidsPattern.empty()) { + setupDebugOutput(options); + } + + RunConfig runConfig; + runConfig.enableAdniPetCore = true; + runConfig.logTag = "adni-pet-core"; + return runNormalization(options, runConfig, fullCommand); + } +}; + +} // namespace + +SpatialNormalizationCLIPtr createCLI() { + return std::make_shared(); +} + +} // namespace Pipeline::SpatialNormalization::Adni diff --git a/localizer/src/spatialNormalizations/adni/AdniPetCoreCLI.h b/localizer/src/spatialNormalizations/adni/AdniPetCoreCLI.h new file mode 100644 index 0000000..670db62 --- /dev/null +++ b/localizer/src/spatialNormalizations/adni/AdniPetCoreCLI.h @@ -0,0 +1,11 @@ +#pragma once +#include "../../core/interfaces/ISpatialNormalizationCLI.h" + +namespace Pipeline::SpatialNormalization::Adni { + +SpatialNormalizationCLIPtr createCLI(); + +} // namespace Pipeline::SpatialNormalization::Adni + + + diff --git a/localizer/src/spatialNormalizations/rigid/RigidCLI.cpp b/localizer/src/spatialNormalizations/rigid/RigidCLI.cpp new file mode 100644 index 0000000..8085bae --- /dev/null +++ b/localizer/src/spatialNormalizations/rigid/RigidCLI.cpp @@ -0,0 +1,202 @@ +#include "RigidCLI.h" +#include "../CLIOptions.h" +#include "../../core/common/Filesystem.h" +#include "../../core/common/PathUtils.h" +#include "../../core/config/Version.h" +#include "../../core/di/Bootstrap.h" +#include "../../core/services/IFileService.h" +#include "../../core/services/ISpatialNormalizationService.h" +#include "../../metrics/shared/BatchLogging.h" +#include +#include +#include +#include +#include +#include +#include + +namespace Pipeline::SpatialNormalization::Rigid { + +namespace { + +constexpr const char* kBatchOutputSuffix = "_rigid_aligned.nii"; + +std::string resolveDebugBasePath(const NormalizeCommandOptions& options, const std::string& outputPath) { + if (!options.enableDebugOutput || outputPath.empty()) { + return {}; + } + return Common::path::deriveDebugBasePath(outputPath); +} + +int processSingleImage(const NormalizeCommandOptions& options, + const std::string& inputPath, + const std::string& outputPath, + const std::string& debugOutputBasePath, + bool logCompletion) { + BootstrapOptions bootstrapOptions; + bootstrapOptions.configPath = options.configPath; + bootstrapOptions.enableConfigDebug = options.enableDebugOutput; + bootstrapOptions.logTag = "rigid"; + + auto container = Pipeline::buildCoreContainer(bootstrapOptions); + auto spatialService = container->resolve(); + auto fileService = container->resolve(); + + SpatialNormalizationRequest request; + request.inputPath = inputPath; + request.skip = false; + request.options.rigidOnly = true; + request.options.useIterativeRigid = options.useIterativeRigid; + request.options.enableDebugOutput = options.enableDebugOutput; + request.options.debugOutputBasePath = debugOutputBasePath; + + try { + auto output = spatialService->normalize(request); + fileService->saveNormalizedImage({output.spatiallyNormalizedImage, outputPath}); + if (logCompletion) { + std::cout << "[rigid] Rigid alignment complete. Output saved to " + << outputPath << std::endl; + } + } catch (const std::exception& ex) { + std::cerr << "[rigid] Processing failed: " << ex.what() << std::endl; + return EXIT_FAILURE; + } + + return EXIT_SUCCESS; +} + +int runSingleRigid(const NormalizeCommandOptions& options, const std::string& fullCommand) { + if (!Common::fs::ensureParentDirectory(options.outputPath)) { + std::cerr << "[rigid] Failed to prepare output directory: " + << options.outputPath << std::endl; + return EXIT_FAILURE; + } + + std::cout << "[rigid] Starting processing: " << fullCommand << std::endl; + return processSingleImage( + options, + options.inputPath, + options.outputPath, + options.debugOutputBasePath, + true); +} + +int runBatchRigid(const NormalizeCommandOptions& options, const std::string& fullCommand) { + const std::filesystem::path inputDir = Common::path::fromUtf8(options.inputPath); + const std::filesystem::path outputDir = Common::path::fromUtf8(options.outputPath); + const bool hasBidsPattern = !options.bidsPattern.empty(); + + if (!std::filesystem::exists(inputDir) || !std::filesystem::is_directory(inputDir)) { + std::cerr << "[rigid] Input directory does not exist: " + << options.inputPath << std::endl; + return EXIT_FAILURE; + } + if (!Common::fs::ensureDirectory(outputDir)) { + std::cerr << "[rigid] Output path must be a directory: " + << options.outputPath << std::endl; + return EXIT_FAILURE; + } + if (!Common::fs::isDirectoryEmpty(outputDir)) { + std::cerr << "[rigid] Output directory must be empty for batch rigid processing." + << std::endl; + return EXIT_FAILURE; + } + + std::vector files; + try { + files = Common::fs::collectInputNiftiFiles(inputDir, options.bidsPattern); + } catch (const std::regex_error& ex) { + std::cerr << "[rigid] Invalid --bids regex: " << ex.what() << std::endl; + return EXIT_FAILURE; + } + if (files.empty()) { + std::cout << "[rigid] No " << (hasBidsPattern ? "PET-BIDS NIfTI" : "NIfTI") + << " files found in " << Common::path::toUtf8(inputDir) << std::endl; + return EXIT_SUCCESS; + } + + std::cout << "[rigid] Starting batch processing of " << files.size() + << " files: " << fullCommand << std::endl; + + auto batchInfo = Pipeline::Metrics::Shared::openBatchInfo( + outputDir, fullCommand, SOFTWARE_VERSION, options.configPath, inputDir); + Pipeline::Metrics::Shared::BatchSummary summary; + + for (const auto& inputFile : files) { + summary.processed++; + const std::string outputPath = + Common::fs::buildOutputPath(inputFile, outputDir, kBatchOutputSuffix); + const std::string inputPath = Common::path::toUtf8(inputFile); + const std::string fileLabel = Common::path::toUtf8(inputFile.filename()); + const std::string debugBase = resolveDebugBasePath(options, outputPath); + + try { + const int result = processSingleImage(options, inputPath, outputPath, debugBase, false); + if (result != EXIT_SUCCESS) { + throw std::runtime_error("Rigid alignment failed."); + } + + summary.succeeded++; + std::cout << "[rigid][batch] Processed " << fileLabel << std::endl; + Pipeline::Metrics::Shared::appendSuccessEntry(batchInfo, fileLabel); + } catch (const std::exception& ex) { + summary.failed++; + std::cerr << "[rigid][batch] Failed " << fileLabel << ": " + << ex.what() << std::endl; + Pipeline::Metrics::Shared::appendFailureEntry(batchInfo, fileLabel, ex.what()); + } + } + + Pipeline::Metrics::Shared::finalizeBatchInfo(batchInfo, summary); + std::cout << "[rigid] Batch complete. Success: " << summary.succeeded + << ", Failed: " << summary.failed << std::endl; + + return summary.failed == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} + +class RigidCLI final : public ISpatialNormalizationCLI { +public: + std::string getSubcommandName() const override { + return "rigid"; + } + + std::string getDescription() const override { + return "Run rigid-only alignment and update affine matrix without resampling"; + } + + void configureArguments(argparse::ArgumentParser& parser) override { + addBaseArguments(parser); + parser.add_argument("-i", "--iterative") + .help("Use iterative rigid transformation") + .default_value(false) + .implicit_value(true); + } + + int execute(const argparse::ArgumentParser& parser, const std::string& fullCommand) override { + NormalizeCommandOptions options; + options.inputPath = parser.get("--input"); + options.outputPath = parser.get("--output"); + options.configPath = parser.get("--config"); + options.useIterativeRigid = parser.get("--iterative"); + options.enableDebugOutput = parser.get("--debug"); + options.batchMode = parser.get("--batch"); + options.bidsPattern = parser.get("--bids"); + + if (!options.batchMode && options.bidsPattern.empty() && options.enableDebugOutput) { + setupDebugOutput(options); + } + + if (options.batchMode || !options.bidsPattern.empty()) { + return runBatchRigid(options, fullCommand); + } + return runSingleRigid(options, fullCommand); + } +}; + +} // namespace + +SpatialNormalizationCLIPtr createCLI() { + return std::make_shared(); +} + +} // namespace Pipeline::SpatialNormalization::Rigid diff --git a/localizer/src/spatialNormalizations/rigid/RigidCLI.h b/localizer/src/spatialNormalizations/rigid/RigidCLI.h new file mode 100644 index 0000000..7ecebc9 --- /dev/null +++ b/localizer/src/spatialNormalizations/rigid/RigidCLI.h @@ -0,0 +1,8 @@ +#pragma once +#include "../../core/interfaces/ISpatialNormalizationCLI.h" + +namespace Pipeline::SpatialNormalization::Rigid { + +SpatialNormalizationCLIPtr createCLI(); + +} // namespace Pipeline::SpatialNormalization::Rigid diff --git a/localizer/src/spatialNormalizations/standard/NormalizeCLI.cpp b/localizer/src/spatialNormalizations/standard/NormalizeCLI.cpp new file mode 100644 index 0000000..b53cc09 --- /dev/null +++ b/localizer/src/spatialNormalizations/standard/NormalizeCLI.cpp @@ -0,0 +1,115 @@ +#include "NormalizeCLI.h" +#include "../CLIOptions.h" +#include "../../core/common/Filesystem.h" +#include "../../core/common/NormalizationContracts.h" +#include "../../core/di/Bootstrap.h" +#include "../../core/services/IFileService.h" +#include "../../core/services/ISpatialNormalizationService.h" +#include +#include + +namespace Pipeline::SpatialNormalization::Standard { + +namespace { + +struct RunConfig { + bool enableAdniPetCore = false; + const char* logTag = "normalize"; +}; + +int runNormalization(const NormalizeCommandOptions& options, + const RunConfig& config, + const std::string& fullCommand) { + if (!Common::fs::ensureParentDirectory(options.outputPath)) { + std::cerr << "[" << config.logTag << "] Failed to prepare output directory: " + << options.outputPath << std::endl; + return EXIT_FAILURE; + } + + BootstrapOptions bootstrapOptions; + bootstrapOptions.configPath = options.configPath; + bootstrapOptions.enableConfigDebug = options.enableDebugOutput; + bootstrapOptions.logTag = config.logTag; + + auto container = Pipeline::buildCoreContainer(bootstrapOptions); + SpatialNormalizationRequest request; + request.inputPath = options.inputPath; + request.skip = false; + request.options.useIterativeRigid = options.useIterativeRigid; + request.options.useManualFOV = options.useManualFOV; + request.options.enableDebugOutput = options.enableDebugOutput; + request.options.debugOutputBasePath = options.debugOutputBasePath; + request.options.enableAdniPetCore = config.enableAdniPetCore; + + std::cout << "[" << config.logTag << "] Starting processing: " << fullCommand << std::endl; + auto spatialService = container->resolve(); + auto fileService = container->resolve(); + + try { + auto output = spatialService->normalize(request); + fileService->saveNormalizedImage({output.spatiallyNormalizedImage, options.outputPath}); + std::cout << "\n[" << config.logTag << "] Spatial normalization complete. Normalized image saved to " + << options.outputPath << std::endl; + } catch (const std::exception& ex) { + std::cerr << "[" << config.logTag << "] Processing failed: " << ex.what() << std::endl; + return EXIT_FAILURE; + } + + std::cout << "[" << config.logTag << "] Processing completed successfully." << std::endl; + return EXIT_SUCCESS; +} + +class NormalizeCLI final : public ISpatialNormalizationCLI { +public: + std::string getSubcommandName() const override { + return "normalize"; + } + + std::string getDescription() const override { + return "Perform spatial normalization on PET images"; + } + + void configureArguments(argparse::ArgumentParser& parser) override { + addBaseArguments(parser); + addSpatialNormalizationArguments(parser); + + parser.add_argument("--method") + .help("Normalization method") + .default_value("rigid_voxelmorph"); + } + + int execute(const argparse::ArgumentParser& parser, const std::string& fullCommand) override { + NormalizeCommandOptions options; + options.inputPath = parser.get("--input"); + options.outputPath = parser.get("--output"); + options.configPath = parser.get("--config"); + options.normalizationMethod = parser.get("--method"); + options.useIterativeRigid = parser.get("--iterative"); + options.useManualFOV = parser.get("--manual-fov"); + options.enableDebugOutput = parser.get("--debug"); + options.enableADNIStyle = false; + options.batchMode = parser.get("--batch"); + options.bidsPattern = parser.get("--bids"); + + if (options.batchMode || !options.bidsPattern.empty()) { + std::cerr << "[normalize] --batch and --bids are not supported by normalize. " + << "Use adni-pet-core for batch normalization." << std::endl; + return EXIT_FAILURE; + } + + setupDebugOutput(options); + + RunConfig runConfig; + runConfig.enableAdniPetCore = false; + runConfig.logTag = "normalize"; + return runNormalization(options, runConfig, fullCommand); + } +}; + +} // namespace + +SpatialNormalizationCLIPtr createCLI() { + return std::make_shared(); +} + +} // namespace Pipeline::SpatialNormalization::Standard diff --git a/localizer/src/spatialNormalizations/standard/NormalizeCLI.h b/localizer/src/spatialNormalizations/standard/NormalizeCLI.h new file mode 100644 index 0000000..8aa0448 --- /dev/null +++ b/localizer/src/spatialNormalizations/standard/NormalizeCLI.h @@ -0,0 +1,11 @@ +#pragma once +#include "../../core/interfaces/ISpatialNormalizationCLI.h" + +namespace Pipeline::SpatialNormalization::Standard { + +SpatialNormalizationCLIPtr createCLI(); + +} // namespace Pipeline::SpatialNormalization::Standard + + + diff --git a/localizer/src/tests/conftest.py b/localizer/src/tests/conftest.py index 0c2a69c..04cbbd8 100644 --- a/localizer/src/tests/conftest.py +++ b/localizer/src/tests/conftest.py @@ -13,11 +13,11 @@ @pytest.fixture(scope="session") def exe_path(): """ - Fixture to locate the CentiloidCalculator executable. + Fixture to locate the DCCCcore executable. """ - # User suggested: exe_path = TEST_DIR / '../install/bin/CentiloidCalculator' + # User suggested: exe_path = TEST_DIR / '../install/bin/DCCCcore' # We handle Windows extension automatically - base_path = TEST_DIR / '../install/bin/CentiloidCalculator' + base_path = TEST_DIR / '../install/bin/DCCCcore' if sys.platform == 'win32': exe = base_path.with_suffix('.exe') diff --git a/localizer/src/tests/test_abetaindex_cli.py b/localizer/src/tests/test_abetaindex_cli.py new file mode 100644 index 0000000..f63b156 --- /dev/null +++ b/localizer/src/tests/test_abetaindex_cli.py @@ -0,0 +1,38 @@ +from pathlib import Path + +import pytest + + +SAMPLE_FILES = ["YC03_PET.nii", "ED02_PET.nii"] + + +@pytest.mark.parametrize("filename", SAMPLE_FILES) +def test_abetaindex_cli_on_samples(run_subprocess, tmp_path, test_files, filename): + """ + Run abetaindex CLI on provided PET samples and print the computed coefficient. + """ + input_path = Path(test_files["test_dir"]) / "test_acc_centiloid_centaurz" / filename + if not input_path.exists(): + pytest.skip(f"Missing test input: {input_path}") + + output_path = tmp_path / f"{filename}_abetaindex.nii" + args = [ + "abetaindex", + "--input", + str(input_path), + "--output", + str(output_path), + ] + + result = run_subprocess(args) + + print(f"\n[abetaindex][{filename}] STDOUT:\n{result.stdout}") + if result.stderr: + print(f"\n[abetaindex][{filename}] STDERR:\n{result.stderr}") + + assert result.returncode == 0, ( + f"abetaindex command failed for {filename}.\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + assert output_path.exists(), "Normalized output NIfTI was not created." + assert "AbetaIndex" in result.stdout, "AbetaIndex value missing in CLI output." diff --git a/localizer/src/tests/test_abetaload_cli.py b/localizer/src/tests/test_abetaload_cli.py new file mode 100644 index 0000000..c01bedf --- /dev/null +++ b/localizer/src/tests/test_abetaload_cli.py @@ -0,0 +1,40 @@ +from pathlib import Path + +import pytest + + +SAMPLE_FILES = ["YC03_PET.nii", "ED02_PET.nii"] + +# TODO: test on more samples and write a brief reproduction report + + +@pytest.mark.parametrize("filename", SAMPLE_FILES) +def test_abetaload_cli_on_samples(run_subprocess, tmp_path, test_files, filename): + """ + Run abetaload CLI on provided PET samples and print decomposition outputs. + """ + input_path = Path(test_files["test_dir"]) / "test_acc_centiloid_centaurz" / filename + if not input_path.exists(): + pytest.skip(f"Missing test input: {input_path}") + + output_path = tmp_path / f"{filename}_abetaload.nii" + args = [ + "abetaload", + "--input", + str(input_path), + "--output", + str(output_path), + ] + + result = run_subprocess(args) + + print(f"\n[abetaload][{filename}] STDOUT:\n{result.stdout}") + if result.stderr: + print(f"\n[abetaload][{filename}] STDERR:\n{result.stderr}") + + assert result.returncode == 0, ( + f"abetaload command failed for {filename}.\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + assert output_path.exists(), "Normalized output NIfTI was not created." + assert "Abeta_load" in result.stdout, "Abeta_load value missing in CLI output." diff --git a/localizer/src/tests/test_acc_centiloid_centaurz/gt_detailed.csv b/localizer/src/tests/test_acc_centiloid_centaurz/gt_detailed.csv new file mode 100644 index 0000000..e92155f --- /dev/null +++ b/localizer/src/tests/test_acc_centiloid_centaurz/gt_detailed.csv @@ -0,0 +1,19 @@ +image,metric,tracer,value,region,value_type +AD03_19500101_FTP_80100.nii,centaurz,FTP,2.108711008,universal,suvr +AD03_19500101_FTP_80100.nii,centaurz,FTP,5.356204588,mesial_temporal,metric +AD03_19500101_FTP_80100.nii,centaurz,FTP,1.67621925,mesial_temporal,suvr +AD03_19500101_FTP_80100.nii,centaurz,FTP,11.3457191,meta_temporal,metric +AD03_19500101_FTP_80100.nii,centaurz,FTP,2.062989892,meta_temporal,suvr +AD03_19500101_FTP_80100.nii,centaurz,FTP,13.25077162,temporo_parietal,metric +AD03_19500101_FTP_80100.nii,centaurz,FTP,2.121510663,temporo_parietal,suvr +AD03_19500101_FTP_80100.nii,centaurz,FTP,12.20366527,frontal,metric +AD03_19500101_FTP_80100.nii,centaurz,FTP,2.171719661,frontal,suvr +CU03_19500101_FTP_80100.nii,centaurz,FTP,1.072486729,universal,suvr +CU03_19500101_FTP_80100.nii,centaurz,FTP,-1.086214566,mesial_temporal,metric +CU03_19500101_FTP_80100.nii,centaurz,FTP,1.057944859,mesial_temporal,suvr +CU03_19500101_FTP_80100.nii,centaurz,FTP,-1.169203936,meta_temporal,metric +CU03_19500101_FTP_80100.nii,centaurz,FTP,1.096586569,meta_temporal,suvr +CU03_19500101_FTP_80100.nii,centaurz,FTP,-1.246070006,temporo_parietal,metric +CU03_19500101_FTP_80100.nii,centaurz,FTP,1.067194909,temporo_parietal,suvr +CU03_19500101_FTP_80100.nii,centaurz,FTP,-1.547274116,frontal,metric +CU03_19500101_FTP_80100.nii,centaurz,FTP,0.987314891,frontal,suvr diff --git a/localizer/src/tests/test_acc_centiloid_centaurz_cli.py b/localizer/src/tests/test_acc_centiloid_centaurz_cli.py index eacebd6..b97d037 100644 --- a/localizer/src/tests/test_acc_centiloid_centaurz_cli.py +++ b/localizer/src/tests/test_acc_centiloid_centaurz_cli.py @@ -11,23 +11,32 @@ CENTILOID_ERROR_THRESHOLD = 10.0 CENTAURZ_ERROR_THRESHOLD = 2.0 +CENTAURZ_SUVR_RELATIVE_ERROR_THRESHOLD = 0.10 + +CENTAURZ_REGION_TO_RESULT_METRIC = { + "universal": "CenTauRz", + "mesial_temporal": "CenTauRz.MesialTemporal", + "meta_temporal": "CenTauRz.MetaTemporal", + "temporo_parietal": "CenTauRz.TemporoParietal", + "frontal": "CenTauRz.Frontal", +} # Define TEST_DIR relative to this file to read gt.csv during collection CURRENT_DIR = Path(__file__).parent.resolve() def read_gt_data() -> List[Dict[str, Any]]: - gt_file = CURRENT_DIR / "test_acc_centiloid_centaurz" / "gt.csv" - if not gt_file.exists(): - return [] + gt_dir = CURRENT_DIR / "test_acc_centiloid_centaurz" + gt_files = [gt_dir / "gt.csv", gt_dir / "gt_detailed.csv"] try: - # Read CSV using pandas - df = pd.read_csv(gt_file) - # Filter out rows with missing image or metric + frames = [pd.read_csv(path) for path in gt_files if path.exists()] + if not frames: + return [] + df = pd.concat(frames, ignore_index=True) + df = df.drop_duplicates() df = df.dropna(subset=['image', 'metric']) - # Convert to list of dicts return df.to_dict('records') except Exception as e: - print(f"Warning: Failed to read gt.csv using pandas: {e}") + print(f"Warning: Failed to read Centiloid/CenTauRz gt.csv files using pandas: {e}") return [] GT_DATA = read_gt_data() @@ -43,13 +52,15 @@ def test_quantification_accuracy(self, row, exe_path, test_files, tmp_path): metric = row['metric'] tracer = row['tracer'] gt_value = float(row['value']) + region = self._normalize_region(row.get("region")) + value_type = self._normalize_value_type(row.get("value_type")) # Locate source file # Inputs are in 'tests/test_acc_centiloid_centaurz' src_file = test_files['test_dir'] / "test_acc_centiloid_centaurz" / image_name if not src_file.exists(): - self._record_result(image_name, metric, tracer, gt_value, "Skip", "N/A") + self._record_result(image_name, metric, tracer, region, value_type, gt_value, "Skip", "N/A") pytest.skip(f"Input file {image_name} not found in tests/test_acc_centiloid_centaurz") # Prepare isolated environment for batch mode to ensure clean CSV and no interference @@ -67,7 +78,7 @@ def test_quantification_accuracy(self, row, exe_path, test_files, tmp_path): elif metric.lower() == 'centaurz': subcmd = 'centaurz' else: - self._record_result(image_name, metric, tracer, gt_value, "ERR", "N/A") + self._record_result(image_name, metric, tracer, region, value_type, gt_value, "ERR", "N/A") pytest.fail(f"Unknown metric: {metric}") # Construct command @@ -79,6 +90,8 @@ def test_quantification_accuracy(self, row, exe_path, test_files, tmp_path): "--output", str(output_dir), "--batch" ] + if metric.lower() == "centaurz" and region != "universal": + cmd.append("--report-detailed-regions") print(f"Running: {' '.join(cmd)}") @@ -86,59 +99,100 @@ def test_quantification_accuracy(self, row, exe_path, test_files, tmp_path): result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: - self._record_result(image_name, metric, tracer, gt_value, "FAIL", "N/A") + self._record_result(image_name, metric, tracer, region, value_type, gt_value, "FAIL", "N/A") pytest.fail(f"Process failed with return code {result.returncode}:\n{result.stderr}") # Parse output CSV results_csv = output_dir / "results.csv" if not results_csv.exists(): - self._record_result(image_name, metric, tracer, gt_value, "No CSV", "N/A") + self._record_result(image_name, metric, tracer, region, value_type, gt_value, "No CSV", "N/A") pytest.fail("results.csv was not created") pred_value = None try: - # Use pandas to read results.csv as well for consistency res_df = pd.read_csv(results_csv) - # Find the row for our image - # Filename column might be just filename or full path depending on implementation - # Implementation says: csvFile << inputFile.filename().string() - - # Check if 'Filename' column exists if 'Filename' in res_df.columns: file_row = res_df[res_df['Filename'] == image_name] + result_metric_name = self._resolve_result_metric_name(metric, region) + if result_metric_name and 'Metric' in res_df.columns: + file_row = file_row[file_row['Metric'] == result_metric_name] if not file_row.empty: - if tracer in file_row.columns: - val = file_row.iloc[0][tracer] + result_column = self._resolve_result_column(metric, tracer, value_type) + if result_column in file_row.columns: + val = file_row.iloc[0][result_column] if pd.notna(val): pred_value = float(val) except Exception as e: - self._record_result(image_name, metric, tracer, gt_value, "ReadErr", "N/A") + self._record_result(image_name, metric, tracer, region, value_type, gt_value, "ReadErr", "N/A") pytest.fail(f"Error reading results.csv: {e}") if pred_value is None: - self._record_result(image_name, metric, tracer, gt_value, "No Val", "N/A") - pytest.fail(f"Value for tracer {tracer} not found in results.csv or is invalid") + self._record_result(image_name, metric, tracer, region, value_type, gt_value, "No Val", "N/A") + pytest.fail( + f"Value for {metric}/{region}/{value_type} ({tracer}) " + f"not found in results.csv or is invalid" + ) diff = pred_value - gt_value - self._record_result(image_name, metric, tracer, gt_value, pred_value, diff) + self._record_result(image_name, metric, tracer, region, value_type, gt_value, pred_value, diff) - # Accuracy Assertion abs_diff = abs(diff) if metric.lower() == 'centiloid': assert abs_diff <= CENTILOID_ERROR_THRESHOLD, f"Centiloid error {abs_diff:.2f} > {CENTILOID_ERROR_THRESHOLD}" elif metric.lower() == 'centaurz': - assert abs_diff <= CENTAURZ_ERROR_THRESHOLD, f"Centaurz error {abs_diff:.2f} > {CENTAURZ_ERROR_THRESHOLD}" + if value_type == "suvr": + relative_error = abs_diff / abs(gt_value) if abs(gt_value) > 1e-8 else abs_diff + assert relative_error <= CENTAURZ_SUVR_RELATIVE_ERROR_THRESHOLD, ( + f"CenTauRz SUVr relative error {relative_error:.4f} > " + f"{CENTAURZ_SUVR_RELATIVE_ERROR_THRESHOLD}" + ) + else: + assert abs_diff <= CENTAURZ_ERROR_THRESHOLD, ( + f"Centaurz error {abs_diff:.2f} > {CENTAURZ_ERROR_THRESHOLD}" + ) + + @staticmethod + def _normalize_region(region_value: Any) -> str: + if pd.isna(region_value): + return "universal" + normalized = str(region_value).strip().lower().replace(" ", "_").replace("-", "_") + return normalized or "universal" + + @staticmethod + def _normalize_value_type(value_type: Any) -> str: + if pd.isna(value_type): + return "metric" + normalized = str(value_type).strip().lower() + return normalized or "metric" - def _record_result(self, image, metric, tracer, gt, pred, diff): + @staticmethod + def _resolve_result_metric_name(metric: str, region: str) -> str: + if metric.lower() != "centaurz": + return "" + if region not in CENTAURZ_REGION_TO_RESULT_METRIC: + raise AssertionError(f"Unsupported CenTauRz region in gt.csv: {region}") + return CENTAURZ_REGION_TO_RESULT_METRIC[region] + + @staticmethod + def _resolve_result_column(metric: str, tracer: str, value_type: str) -> str: + if metric.lower() == "centaurz" and value_type == "suvr": + return "SUVr" + return tracer + + def _record_result(self, image, metric, tracer, region, value_type, gt, pred, diff): """Helper to format and record results.""" def fmt(val): if isinstance(val, (int, float)): return f"{val:.1f}" return str(val) - + + metric_label = metric + if metric.lower() == "centaurz": + metric_label = f"{metric}:{region}:{value_type}" + ACC_TEST_RESULTS.append({ 'image': image, - 'metric': metric, + 'metric': metric_label, 'tracer': tracer, 'gt': fmt(gt), 'pred': fmt(pred), diff --git a/localizer/src/tests/test_adad_cli.py b/localizer/src/tests/test_adad_cli.py new file mode 100644 index 0000000..4c7bf56 --- /dev/null +++ b/localizer/src/tests/test_adad_cli.py @@ -0,0 +1,143 @@ +import pytest +from pathlib import Path +import shutil + + +def _expected_adad_outputs(base: Path): + suffixes = ["", "_stripped_image", "_stripped_component", "_AD_prob_map"] + return [_with_suffix(base, suffix) for suffix in suffixes] + +def _with_suffix(base: Path, suffix: str) -> Path: + return base.with_name(f"{base.stem}{suffix}{base.suffix}") + +class TestADADCLI: + """Targeted coverage for the ADAD pipeline.""" + + @pytest.mark.parametrize("modality", ["abeta", "tau"]) + def test_generates_all_outputs(self, modality, run_subprocess, tmp_path, test_files): + output_path = tmp_path / f"adad_{modality}.nii" + result = run_subprocess( + [ + "adad", + "--input", + str(test_files["input"]), + "--output", + str(output_path), + "--modality", + modality, + ] + ) + + assert result.returncode == 0, ( + f"adad command failed for modality={modality}.\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + + for path in _expected_adad_outputs(output_path): + assert path.exists(), f"Expected output file missing: {path}" + + def test_skip_normalization(self, run_subprocess, tmp_path, test_files): + output_path = tmp_path / "adad_skip.nii" + result = run_subprocess( + [ + "adad", + "--input", + str(test_files["input"]), + "--output", + str(output_path), + "--skip-normalization", + ] + ) + + assert result.returncode == 0, ( + "adad command failed with --skip-normalization.\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + + for path in _expected_adad_outputs(output_path): + assert path.exists(), f"Expected output file missing: {path}" + + +class TestAdniPetCoreCLI: + """ADNI PET Core normalization coverage.""" + + def test_basic(self, run_subprocess, tmp_path, test_files): + output_path = tmp_path / "adni_pet_core_output.nii" + result = run_subprocess( + [ + "adni-pet-core", + "--input", + str(test_files["input"]), + "--output", + str(output_path), + ] + ) + + assert result.returncode == 0, ( + "adni-pet-core command failed.\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + assert output_path.exists(), "adni-pet-core command did not create the expected output file." + + def test_manual_fov_iterative(self, run_subprocess, tmp_path, test_files): + output_path = tmp_path / "adni_pet_core_iter_manual.nii" + result = run_subprocess( + [ + "adni-pet-core", + "--input", + str(test_files["input"]), + "--output", + str(output_path), + "--iterative", + "--manual-fov", + ] + ) + + assert result.returncode == 0, ( + "adni-pet-core command with iterative/manual flags failed.\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + assert output_path.exists(), "adni-pet-core iterative/manual run did not create the expected output file." + + def test_requires_input(self, run_subprocess, tmp_path): + output_path = tmp_path / "adni_pet_core_missing_input.nii" + result = run_subprocess( + [ + "adni-pet-core", + "--output", + str(output_path), + ] + ) + + assert result.returncode != 0, ( + "adni-pet-core command should fail when --input is omitted.\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + + def test_batch_mode_outputs(self, run_subprocess, tmp_path, test_files): + input_dir = tmp_path / "adni_pet_core_batch_inputs" + output_dir = tmp_path / "adni_pet_core_batch_outputs" + input_dir.mkdir() + output_dir.mkdir() + + shutil.copy(test_files["input"], input_dir / "sample_adni_pet_core.nii") + + result = run_subprocess( + [ + "adni-pet-core", + "--input", + str(input_dir), + "--output", + str(output_dir), + "--batch", + ] + ) + + assert result.returncode == 0, ( + "adni-pet-core batch command failed.\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + + generated = list(output_dir.glob("*.nii")) + assert generated, "adni-pet-core batch run did not produce any output files." + assert (output_dir / "batch_info.txt").exists(), "adni-pet-core batch run missing batch_info.txt" diff --git a/localizer/src/tests/test_batch_cli.py b/localizer/src/tests/test_batch_cli.py index 868d265..f646f5e 100644 --- a/localizer/src/tests/test_batch_cli.py +++ b/localizer/src/tests/test_batch_cli.py @@ -5,10 +5,22 @@ class TestBatchProcessing: """ - Test class for batch processing functionality of CentiloidCalculator. + Test class for batch processing functionality of DCCCcore. """ - def test_batch_processing_success(self, exe_path, data_dir, output_dir, run_subprocess): + @pytest.mark.parametrize( + ("subcommand", "expects_results_csv"), + [("centiloid", True), ("adni-pet-core", False)], + ) + def test_batch_processing_success( + self, + subcommand, + expects_results_csv, + exe_path, + data_dir, + output_dir, + run_subprocess, + ): """ Test that the batch processing command runs successfully and produces output. Equivalent to the original test_batch() function. @@ -16,41 +28,40 @@ def test_batch_processing_success(self, exe_path, data_dir, output_dir, run_subp # Ensure input directory is valid (or populate it if needed) assert data_dir.exists(), "Input directory must exist" - # Construct arguments - # Original: ["centiloid", "--input", "./inputs", "--output", "./outputs", "--batch"] - # We use absolute paths from fixtures to be safe args = [ - "centiloid", + subcommand, "--input", str(data_dir), "--output", str(output_dir), "--batch" ] - # Run command result = run_subprocess(args) - # Assertions - assert result.returncode == 0, f"Command failed with stderr: {result.stderr}" + assert result.returncode == 0, ( + f"{subcommand} batch command failed.\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) - # Verify outputs were created (if input dir was not empty) - # If input dir is empty, the tool might still exit 0 but produce nothing. - # Depending on the tool's behavior, we might want to check stdout. if any(data_dir.iterdir()): - # Check for required batch output files - results_csv = output_dir / "results.csv" batch_info = output_dir / "batch_info.txt" - - assert results_csv.exists(), "results.csv was not created in output directory" assert batch_info.exists(), "batch_info.txt was not created in output directory" - def test_batch_processing_missing_input(self, exe_path, output_dir, run_subprocess, tmp_path): + if expects_results_csv: + results_csv = output_dir / "results.csv" + assert results_csv.exists(), "results.csv was not created in output directory" + + generated = list(output_dir.glob("*.nii")) + assert generated, f"{subcommand} batch run did not produce any output .nii files." + + @pytest.mark.parametrize("subcommand", ["centiloid", "adni-pet-core"]) + def test_batch_processing_missing_input(self, subcommand, exe_path, output_dir, run_subprocess, tmp_path): """ Test behavior when input directory does not exist or is invalid. """ non_existent_input = tmp_path / "non_existent_inputs" args = [ - "centiloid", + subcommand, "--input", str(non_existent_input), "--output", str(output_dir), "--batch" @@ -60,3 +71,60 @@ def test_batch_processing_missing_input(self, exe_path, output_dir, run_subproce # Expect failure or specific error message assert result.returncode != 0 or "Error" in result.stderr or "Error" in result.stdout + + @pytest.mark.parametrize( + ("subcommand", "expects_results_csv", "expected_output"), + [ + ("centiloid", True, "sub-01_ses-01_pet_centiloid.nii"), + ("adni-pet-core", False, "sub-01_ses-01_pet_ADNI_style.nii"), + ("rigid", False, "sub-01_ses-01_pet_rigid_aligned.nii"), + ], + ) + def test_bids_processing_success( + self, + subcommand, + expects_results_csv, + expected_output, + test_files, + output_dir, + run_subprocess, + tmp_path, + ): + bids_root = tmp_path / "bids" + pet_dir = bids_root / "sub-01" / "ses-01" / "pet" + pet_dir.mkdir(parents=True) + bids_input = pet_dir / "sub-01_ses-01_pet.nii" + bids_input.write_bytes(test_files["input"].read_bytes()) + other_pet_dir = bids_root / "sub-02" / "ses-01" / "pet" + other_pet_dir.mkdir(parents=True) + other_bids_input = other_pet_dir / "sub-02_ses-01_pet.nii" + other_bids_input.write_bytes(test_files["input"].read_bytes()) + + args = [ + subcommand, + "--input", str(bids_root), + "--output", str(output_dir), + "--bids", "sub-01", + ] + + result = run_subprocess(args) + + assert result.returncode == 0, ( + f"{subcommand} BIDS command failed.\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + + assert (output_dir / "batch_info.txt").exists(), ( + "batch_info.txt was not created in output directory" + ) + if expects_results_csv: + assert (output_dir / "results.csv").exists(), ( + "results.csv was not created in output directory" + ) + generated = list(output_dir.glob("*.nii")) + assert (output_dir / expected_output).exists(), ( + f"{subcommand} BIDS run did not create {expected_output}." + ) + assert len(generated) == 1, ( + f"{subcommand} BIDS regex should only process sub-01, generated: {generated}" + ) diff --git a/localizer/src/tests/test_centiloid_centaurz_cli.py b/localizer/src/tests/test_centiloid_centaurz_cli.py new file mode 100644 index 0000000..afdb0bb --- /dev/null +++ b/localizer/src/tests/test_centiloid_centaurz_cli.py @@ -0,0 +1,137 @@ +import shutil +import csv +import pytest +class TestCentiloidAndCentaurzCLI: + """Basic CLI coverage for centiloid and centaurz commands.""" + + DETAILED_CENTAURZ_METRICS = { + "CenTauRz", + "CenTauRz.MesialTemporal", + "CenTauRz.MetaTemporal", + "CenTauRz.TemporoParietal", + "CenTauRz.Frontal", + } + + @pytest.mark.parametrize("subcommand", ["centiloid", "centaurz"]) + def test_basic_invocation(self, subcommand, run_subprocess, tmp_path, test_files): + output_path = tmp_path / f"{subcommand}_basic_output.nii" + result = run_subprocess( + [ + subcommand, + "--input", + str(test_files["input"]), + "--output", + str(output_path), + ] + ) + + assert result.returncode == 0, ( + f"{subcommand} command failed.\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + + @pytest.mark.parametrize("subcommand", ["centiloid", "centaurz"]) + def test_skip_normalization(self, subcommand, run_subprocess, tmp_path, test_files): + output_path = tmp_path / f"{subcommand}_skip_output.nii" + result = run_subprocess( + [ + subcommand, + "--input", + str(test_files["input"]), + "--output", + str(output_path), + "--skip-normalization", + ] + ) + + assert result.returncode == 0, ( + f"{subcommand} command failed with --skip-normalization.\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + + @pytest.mark.parametrize( + ("subcommand", "glob_suffix"), + [("centiloid", "*.nii"), ("centaurz", "*.nii")], + ) + def test_batch_mode_outputs(self, subcommand, glob_suffix, run_subprocess, tmp_path, test_files): + input_dir = tmp_path / f"{subcommand}_batch_inputs" + output_dir = tmp_path / f"{subcommand}_batch_outputs" + input_dir.mkdir() + output_dir.mkdir() + + shutil.copy(test_files["input"], input_dir / f"sample_{subcommand}.nii") + + result = run_subprocess( + [ + subcommand, + "--input", + str(input_dir), + "--output", + str(output_dir), + "--batch", + ] + ) + + assert result.returncode == 0, ( + f"{subcommand} batch command failed.\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + + generated = list(output_dir.glob(glob_suffix)) + assert generated, f"Batch run did not produce any {subcommand} outputs." + assert (output_dir / "results.csv").exists(), "Batch run missing results.csv" + assert (output_dir / "batch_info.txt").exists(), "Batch run missing batch_info.txt" + + def test_centaurz_detailed_regions_stdout(self, run_subprocess, tmp_path, test_files): + output_path = tmp_path / "centaurz_detailed_output.nii" + result = run_subprocess( + [ + "centaurz", + "--input", + str(test_files["input"]), + "--output", + str(output_path), + "--skip-normalization", + "--report-detailed-regions", + ] + ) + + assert result.returncode == 0, ( + "centaurz detailed-region command failed.\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + for metric_name in self.DETAILED_CENTAURZ_METRICS - {"CenTauRz"}: + assert f"Metric: {metric_name}" in result.stdout + assert "SUVr:" in result.stdout + + def test_centaurz_detailed_regions_batch_csv(self, run_subprocess, tmp_path, test_files): + input_dir = tmp_path / "centaurz_detailed_batch_inputs" + output_dir = tmp_path / "centaurz_detailed_batch_outputs" + input_dir.mkdir() + output_dir.mkdir() + + image_name = "sample_centaurz_detailed.nii" + shutil.copy(test_files["input"], input_dir / image_name) + + result = run_subprocess( + [ + "centaurz", + "--input", + str(input_dir), + "--output", + str(output_dir), + "--batch", + "--report-detailed-regions", + ] + ) + + assert result.returncode == 0, ( + "centaurz detailed-region batch command failed.\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + + with (output_dir / "results.csv").open(newline="") as handle: + rows = list(csv.DictReader(handle)) + + metrics = {row["Metric"] for row in rows if row["Filename"] == image_name} + assert self.DETAILED_CENTAURZ_METRICS.issubset(metrics) diff --git a/localizer/src/tests/test_fillstates_cli.py b/localizer/src/tests/test_fillstates_cli.py index 95b3e86..dc6e076 100644 --- a/localizer/src/tests/test_fillstates_cli.py +++ b/localizer/src/tests/test_fillstates_cli.py @@ -1,7 +1,6 @@ import pytest from pathlib import Path - class TestFillStatesCLI: """Tests for the fillstates CLI subcommand.""" @@ -50,6 +49,27 @@ def test_fillstates_cli_non_existent_tracer(self, run_subprocess, tmp_path, test f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" ) + def test_fillstates_skip_normalization(self, run_subprocess, tmp_path, test_files): + """ + Ensure skipping normalization still produces every artifact for a valid tracer. + """ + output_path = tmp_path / "output_fillstates_skip.nii" + args = [ + "fillstates", + "--input", + str(test_files["input"]), + "--output", + str(output_path), + "--tracer", + "fbp", + "--skip-normalization", + ] + result = run_subprocess(args) + assert result.returncode == 0, ( + "fillstates command failed with --skip-normalization.\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + @pytest.mark.parametrize("tracer", ['fbp']) def test_fillstates_cli_unsupported_configuration(self, run_subprocess, tmp_path, test_files, tracer): """ @@ -72,5 +92,3 @@ def test_fillstates_cli_unsupported_configuration(self, run_subprocess, tmp_path f"fillstates should have failed for tracer={tracer}.\n" f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" ) - - diff --git a/localizer/src/tests/test_normalize_cli.py b/localizer/src/tests/test_normalize_cli.py new file mode 100644 index 0000000..20e5932 --- /dev/null +++ b/localizer/src/tests/test_normalize_cli.py @@ -0,0 +1,75 @@ +import pytest + +class TestNormalizeCLI: + """Standard normalize command coverage.""" + + def test_basic(self, run_subprocess, tmp_path, test_files): + output_path = tmp_path / "normalize_output.nii" + result = run_subprocess( + [ + "normalize", + "--input", + str(test_files["input"]), + "--output", + str(output_path), + ] + ) + + assert result.returncode == 0, ( + "normalize command failed.\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + assert output_path.exists(), "normalize command did not create the expected output file." + + def test_requires_input(self, run_subprocess, tmp_path): + output_path = tmp_path / "normalize_missing_input.nii" + result = run_subprocess( + [ + "normalize", + "--output", + str(output_path), + ] + ) + + assert result.returncode != 0, ( + "normalize command should fail when --input is omitted.\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + + def test_iterative_flag(self, run_subprocess, tmp_path, test_files): + output_path = tmp_path / "normalize_iterative_output.nii" + result = run_subprocess( + [ + "normalize", + "--input", + str(test_files["input"]), + "--output", + str(output_path), + "--iterative", + ] + ) + + assert result.returncode == 0, ( + "normalize command with --iterative failed.\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + assert output_path.exists(), "normalize --iterative did not create the expected output file." + + def test_rigid_iterative_flag(self, run_subprocess, tmp_path, test_files): + output_path = tmp_path / "rigid_iterative_output.nii" + result = run_subprocess( + [ + "rigid", + "--input", + str(test_files["input"]), + "--output", + str(output_path), + "--iterative", + ] + ) + + assert result.returncode == 0, ( + "rigid command with --iterative failed.\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + assert output_path.exists(), "rigid --iterative did not create the expected output file." diff --git a/localizer/src/tests/test_quick_cli.py b/localizer/src/tests/test_quick_cli.py index dfe3fb3..b9feed7 100644 --- a/localizer/src/tests/test_quick_cli.py +++ b/localizer/src/tests/test_quick_cli.py @@ -1,125 +1,79 @@ +from pathlib import Path from typing import Any, Dict, List, cast import pytest TEST_CASES: List[Dict[str, Any]] = [ + {"id": "help_message", "args": ["--help"], "expected_failure": False}, + {"id": "version_information", "args": ["--version"], "expected_failure": False}, + {"id": "metrics_listing", "args": ["metrics", "--help"], "expected_failure": False}, + {"id": "no_subcommand", "args": [], "expected_failure": True}, + {"id": "refactor_centiloid_help", "args": ["centiloid", "--help"], "expected_failure": False}, + {"id": "refactor_centaur_help", "args": ["centaur", "--help"], "expected_failure": False}, + {"id": "refactor_centaurz_help", "args": ["centaurz", "--help"], "expected_failure": False}, + {"id": "refactor_suvr_help", "args": ["suvr", "--help"], "expected_failure": False}, + {"id": "refactor_fillstates_help", "args": ["fillstates", "--help"], "expected_failure": False}, + {"id": "refactor_abetaindex_help", "args": ["abetaindex", "--help"], "expected_failure": False}, + {"id": "refactor_abetaload_help", "args": ["abetaload", "--help"], "expected_failure": False}, + {"id": "refactor_adad_help", "args": ["adad", "--help"], "expected_failure": False}, + {"id": "normalize_help", "args": ["normalize", "--help"], "expected_failure": False}, + {"id": "adni_pet_core_help", "args": ["adni-pet-core", "--help"], "expected_failure": False}, + {"id": "rigid_help", "args": ["rigid", "--help"], "expected_failure": False}, { - "id": "help_message", - "args": ["--help"], + "id": "refactor_centiloid_basic", + "args": ["centiloid", "--input", "{input}", "--output", "{output}"], "expected_failure": False, }, { - "id": "version_information", - "args": ["--version"], + "id": "refactor_centiloid_with_suvr", + "args": ["centiloid", "--input", "{input}", "--output", "{output}", "--suvr"], "expected_failure": False, }, { - "id": "no_subcommand", - "args": [], - "expected_failure": True, - }, - { - "id": "centiloid_help", - "args": ["centiloid", "--help"], - "expected_failure": False, - }, - { - "id": "centaur_help", - "args": ["centaur", "--help"], - "expected_failure": False, - }, - { - "id": "centaurz_help", - "args": ["centaurz", "--help"], + "id": "refactor_centiloid_skip_normalization", + "args": ["centiloid", "--input", "{input}", "--output", "{output}", "--skip-normalization"], "expected_failure": False, }, { - "id": "suvr_help", - "args": ["suvr", "--help"], + "id": "refactor_centiloid_iterative", + "args": ["centiloid", "--input", "{input}", "--output", "{output}", "--iterative"], "expected_failure": False, }, { - "id": "normalize_help", - "args": ["normalize", "--help"], - "expected_failure": False, - }, - { - "id": "decouple_help", - "args": ["decouple", "--help"], - "expected_failure": False, - }, - { - "id": "centiloid_basic", - "args": [ - "centiloid", - "--input", - "{input}", - "--output", - "{output}", - ], - "expected_failure": False, - }, - { - "id": "centiloid_with_suvr", - "args": [ - "centiloid", - "--input", - "{input}", - "--output", - "{output}", - "--suvr", - ], + "id": "refactor_centaur_basic", + "args": ["centaur", "--input", "{input}", "--output", "{output}"], "expected_failure": False, }, { - "id": "centiloid_skip_normalization", - "args": [ - "centiloid", - "--input", - "{input}", - "--output", - "{output}", - "--skip-normalization", - ], + "id": "refactor_centaurz_basic", + "args": ["centaurz", "--input", "{input}", "--output", "{output}"], "expected_failure": False, }, { - "id": "centiloid_iterative", + "id": "refactor_centaurz_detailed_regions", "args": [ - "centiloid", + "centaurz", "--input", "{input}", "--output", "{output}", - "--iterative", + "--report-detailed-regions", ], "expected_failure": False, }, { - "id": "centaur_basic", - "args": [ - "centaur", - "--input", - "{input}", - "--output", - "{output}", - ], + "id": "refactor_abetaindex_basic", + "args": ["abetaindex", "--input", "{input}", "--output", "{output}"], "expected_failure": False, }, { - "id": "centaurz_basic", - "args": [ - "centaurz", - "--input", - "{input}", - "--output", - "{output}", - ], + "id": "refactor_abetaload_basic", + "args": ["abetaload", "--input", "{input}", "--output", "{output}"], "expected_failure": False, }, { - "id": "suvr_custom", + "id": "refactor_suvr_custom", "args": [ "suvr", "--input", @@ -134,95 +88,25 @@ "expected_failure": False, }, { - "id": "normalize_basic", - "args": ["normalize", "--input", "{input}", "--output", "{output}"], - "expected_failure": False, - }, - { - "id": "normalize_adni_style", - "args": [ - "normalize", - "--input", - "{input}", - "--output", - "{output}", - "--ADNI-PET-core", - ], - "expected_failure": False, - }, - { - "id": "decouple_abeta", - "args": [ - "decouple", - "--input", - "{input}", - "--output", - "{output}", - "--modality", - "abeta", - ], + "id": "refactor_fillstates_basic", + "args": ["fillstates", "--input", "{input}", "--output", "{output}", "--tracer", "fbp"], "expected_failure": False, }, + {"id": "normalize_basic", "args": ["normalize", "--input", "{input}", "--output", "{output}"], "expected_failure": False}, { - "id": "decouple_tau", - "args": [ - "decouple", - "--input", - "{input}", - "--output", - "{output}", - "--modality", - "tau", - ], + "id": "adni_pet_core_basic", + "args": ["adni-pet-core", "--input", "{input}", "--output", "{output}"], "expected_failure": False, }, + {"id": "missing_input_error", "args": ["centiloid", "--output", "{output}"], "expected_failure": True}, { - "id": "missing_input_error", - "args": ["centiloid", "--output", "{output}"], - "expected_failure": True, - }, - { - "id": "suvr_missing_voi_mask", - "args": [ - "suvr", - "--input", - "{input}", - "--output", - "{output}", - "--ref-mask", - "{ref_mask}", - ], - "expected_failure": True, - }, - { - "id": "suvr_missing_ref_mask", - "args": [ - "suvr", - "--input", - "{input}", - "--output", - "{output}", - "--voi-mask", - "{voi_mask}", - ], + "id": "refactor_suvr_missing_voi_mask", + "args": ["suvr", "--input", "{input}", "--output", "{output}", "--ref-mask", "{ref_mask}"], "expected_failure": True, }, { - "id": "decouple_missing_modality", - "args": ["decouple", "--input", "{input}", "--output", "{output}"], - "expected_failure": True, - }, - { - "id": "decouple_invalid_modality", - "args": [ - "decouple", - "--input", - "{input}", - "--output", - "{output}", - "--modality", - "invalid", - ], + "id": "refactor_suvr_missing_ref_mask", + "args": ["suvr", "--input", "{input}", "--output", "{output}", "--voi-mask", "{voi_mask}"], "expected_failure": True, }, ] @@ -264,4 +148,3 @@ def _prepare_args(args_template, tmp_path, test_files): for token in args_template: resolved_args.append(str(placeholder_map.get(token, token))) return resolved_args - diff --git a/localizer/src/tests/test_suvr_cli.py b/localizer/src/tests/test_suvr_cli.py new file mode 100644 index 0000000..e54bbbe --- /dev/null +++ b/localizer/src/tests/test_suvr_cli.py @@ -0,0 +1,40 @@ +class TestSUVRCLI: + """SUVR-specific checks.""" + + def test_basic(self, run_subprocess, tmp_path, test_files): + output_path = tmp_path / "suvr_output.nii" + result = run_subprocess( + [ + "suvr", + "--input", + str(test_files["input"]), + "--output", + str(output_path), + "--voi-mask", + str(test_files["voi_mask"]), + "--ref-mask", + str(test_files["ref_mask"]), + ] + ) + + assert result.returncode == 0, ( + "suvr command failed.\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + + def test_requires_masks(self, run_subprocess, tmp_path, test_files): + output_path = tmp_path / "suvr_missing_masks.nii" + result = run_subprocess( + [ + "suvr", + "--input", + str(test_files["input"]), + "--output", + str(output_path), + ] + ) + + assert result.returncode != 0, ( + "suvr command should fail when masks are omitted.\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) diff --git a/localizer/src/tests/test_ui_metric_command_builder.py b/localizer/src/tests/test_ui_metric_command_builder.py new file mode 100644 index 0000000..c1f0f13 --- /dev/null +++ b/localizer/src/tests/test_ui_metric_command_builder.py @@ -0,0 +1,85 @@ +import sys +import types +from pathlib import Path + + +def _load_metric_calculator_logic(): + # Stub external runtime dependencies unavailable in CI unit-test environment. + if "qt" not in sys.modules: + class _DummyQProcess: + NormalExit = 0 + + sys.modules["qt"] = types.SimpleNamespace( + QProcess=_DummyQProcess, + QProcessEnvironment=object, + ) + if "slicer" not in sys.modules: + sys.modules["slicer"] = types.SimpleNamespace(util=types.SimpleNamespace()) + + repo_root = Path(__file__).resolve().parents[3] + sys.path.insert(0, str(repo_root / "localizer" / "lib")) + from metric_calculator import MetricCalculatorLogic + + return MetricCalculatorLogic + + +MetricCalculatorLogic = _load_metric_calculator_logic() + + +def test_build_command_uses_subcommand_cli_for_centaur(): + logic = MetricCalculatorLogic("/tmp/plugin") + + cmd = logic._build_command( + metric_type="CenTauR", + input_path="in.nii", + output_path="out.nii", + algorithm_style="SPM style", + manual_fov=True, + iterative=True, + skip_normalization=True, + ) + + assert cmd[:2] == [str(logic.executable_path), "centaur"] + assert "--input" in cmd and "--output" in cmd + assert "--manual-fov" in cmd + assert "--manual-fov-placement" not in cmd + assert "--skip-normalization" in cmd + + +def test_build_command_fillstates_requires_tracer_and_no_suvr(): + logic = MetricCalculatorLogic("/tmp/plugin") + + cmd = logic._build_command( + metric_type="Fill States", + input_path="in.nii", + output_path="out.nii", + algorithm_style="SPM style", + manual_fov=False, + iterative=False, + skip_normalization=False, + tracer="ftp", + ) + + assert cmd[1] == "fillstates" + assert "--tracer" in cmd + assert "ftp" in cmd + assert "--suvr" not in cmd + + +def test_build_suvr_command_does_not_reference_legacy_flag(): + logic = MetricCalculatorLogic("/tmp/plugin") + + cmd = logic._build_suvr_command( + input_path="in.nii", + output_path="out.nii", + roi_path="roi.nii", + ref_path="ref.nii", + algorithm_style="SPM style", + manual_fov=True, + iterative=False, + skip_normalization=True, + ) + + assert cmd[:2] == [str(logic.executable_path), "suvr"] + assert "--manual-fov" in cmd + assert "--skip-normalization" in cmd diff --git a/localizer/src/tests/test_unicode_paths_cli.py b/localizer/src/tests/test_unicode_paths_cli.py new file mode 100644 index 0000000..cd09fd3 --- /dev/null +++ b/localizer/src/tests/test_unicode_paths_cli.py @@ -0,0 +1,137 @@ +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + + +pytestmark = pytest.mark.skipif( + sys.platform != "win32", + reason="Windows-specific Unicode path regression tests", +) + + +def _resolve_runtime_config(exe_path: str) -> Path: + exe = Path(exe_path).resolve() + runtime_config = exe.parent / "assets" / "configs" / "config.toml" + if runtime_config.exists(): + return runtime_config + + source_config = Path(__file__).resolve().parent.parent / "assets" / "configs" / "config.toml" + if source_config.exists(): + return source_config + + raise FileNotFoundError("Unable to locate config.toml for Unicode path tests.") + + +def _decode_output(data: bytes | None) -> str: + if not data: + return "" + + for encoding in ("utf-8", "gb18030", sys.getfilesystemencoding()): + try: + return data.decode(encoding) + except UnicodeDecodeError: + continue + + return data.decode("utf-8", errors="replace") + + +def _run_cli(args: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess[bytes]: + return subprocess.run( + args, + capture_output=True, + text=False, + check=False, + cwd=cwd, + ) + + +class TestUnicodePathsCLI: + def test_normalize_with_unicode_config_input_and_output_paths( + self, + exe_path, + tmp_path, + test_files, + ): + case_dir = tmp_path / "中文路径用例" + input_dir = case_dir / "输入目录" + output_dir = case_dir / "输出目录" + config_dir = case_dir / "配置目录" + input_dir.mkdir(parents=True) + output_dir.mkdir(parents=True) + config_dir.mkdir(parents=True) + + input_path = input_dir / "示例输入.nii" + output_path = output_dir / "示例输出.nii" + config_path = config_dir / "配置.toml" + + shutil.copy(test_files["input"], input_path) + shutil.copy(_resolve_runtime_config(exe_path), config_path) + + result = _run_cli( + [ + exe_path, + "normalize", + "--input", + str(input_path), + "--output", + str(output_path), + "--config", + str(config_path), + ] + ) + stdout = _decode_output(result.stdout) + stderr = _decode_output(result.stderr) + + assert result.returncode == 0, ( + "normalize command failed for Unicode config/input/output paths.\n" + f"STDOUT:\n{stdout}\nSTDERR:\n{stderr}" + ) + assert output_path.exists(), "Output file was not created under a Unicode path." + + def test_normalize_with_unicode_runtime_directory( + self, + exe_path, + tmp_path, + test_files, + ): + source_bin_dir = Path(exe_path).resolve().parent + source_runtime_config = source_bin_dir / "assets" / "configs" / "config.toml" + if not source_runtime_config.exists(): + pytest.skip("Installed runtime assets are required for the Unicode runtime directory test.") + + runtime_root = tmp_path / "中文运行目录" + runtime_bin_dir = runtime_root / source_bin_dir.name + shutil.copytree(source_bin_dir, runtime_bin_dir) + + runtime_exe = runtime_bin_dir / Path(exe_path).name + input_dir = runtime_root / "输入数据" + output_dir = runtime_root / "输出数据" + input_dir.mkdir(parents=True) + output_dir.mkdir(parents=True) + + input_path = input_dir / "运行目录输入.nii" + output_path = output_dir / "运行目录输出.nii" + shutil.copy(test_files["input"], input_path) + + result = _run_cli( + [ + str(runtime_exe), + "normalize", + "--input", + str(input_path), + "--output", + str(output_path), + ], + cwd=runtime_bin_dir, + ) + stdout = _decode_output(result.stdout) + stderr = _decode_output(result.stderr) + + assert result.returncode == 0, ( + "normalize command failed when the runtime directory contained Unicode characters.\n" + f"STDOUT:\n{stdout}\nSTDERR:\n{stderr}" + ) + assert output_path.exists(), "Output file was not created from a Unicode runtime directory." diff --git a/localizer/src/utils/common.cpp b/localizer/src/utils/common.cpp deleted file mode 100644 index 33889bd..0000000 --- a/localizer/src/utils/common.cpp +++ /dev/null @@ -1,185 +0,0 @@ -#include "common.h" - -#include -#include - -#ifdef _WIN32 -#ifndef NOMINMAX -#define NOMINMAX -#endif -#include -#elif __APPLE__ -#include -#include -#include -#else -#include -#include -#endif - -namespace Common { - -std::string getExecutablePath() { - std::string executablePath; - -#ifdef _WIN32 - char buffer[MAX_PATH]; - GetModuleFileNameA(NULL, buffer, MAX_PATH); - executablePath = buffer; -#elif __APPLE__ - char path[PATH_MAX]; - uint32_t size = sizeof(path); - if (_NSGetExecutablePath(path, &size) == 0) { - char resolvedPath[PATH_MAX]; - if (realpath(path, resolvedPath) != nullptr) { - executablePath = resolvedPath; - } - } - - // Fallback to current directory if we cannot resolve the executable path - if (executablePath.empty()) { - executablePath = std::filesystem::current_path().string(); - } -#else - char result[PATH_MAX]; - ssize_t count = readlink("/proc/self/exe", result, PATH_MAX); - executablePath = std::string(result, (count > 0) ? count : 0); -#endif - return std::filesystem::path(executablePath).parent_path().string(); -} - -void DivideVoxelsByValue(ImageType::Pointer image, float divisor) { - itk::ImageRegionIterator it(image, - image->GetLargestPossibleRegion()); - for (it.GoToBegin(); !it.IsAtEnd(); ++it) { - it.Set(it.Get() / divisor); - } -} - -double CalculateMeanInMask(ImageType::Pointer image, ImageType::Pointer mask) { - using LabelStatisticsFilterType = - itk::LabelStatisticsImageFilter; - LabelStatisticsFilterType::Pointer labelStatisticsFilter = - LabelStatisticsFilterType::New(); - - labelStatisticsFilter->SetInput(image); - labelStatisticsFilter->SetLabelInput(mask); - labelStatisticsFilter->Update(); - - const unsigned char maskLabel = 1; - if (labelStatisticsFilter->HasLabel(maskLabel)) { - return labelStatisticsFilter->GetMean(maskLabel); - } else { - std::cerr << "Mask does not contain the specified label." << std::endl; - return 0.0; - } -} - -ImageType::Pointer CreateImageFromVector(const std::vector& imageData, - ImageType::SizeType size) { - ImageType::Pointer image = ImageType::New(); - ImageType::IndexType start; - start.Fill(0); - ImageType::RegionType region; - region.SetSize(size); - region.SetIndex(start); - - image->SetRegions(region); - image->Allocate(); - image->FillBuffer(0); - - for (size_t x = 0; x < size[0]; ++x) { - for (size_t y = 0; y < size[1]; ++y) { - for (size_t z = 0; z < size[2]; ++z) { - ImageType::IndexType index; - index[0] = static_cast(x); - index[1] = static_cast(y); - index[2] = static_cast(z); - size_t vectorIndex = x * size[1] * size[2] + y * size[2] + z; - image->SetPixel(index, imageData[vectorIndex]); - } - } - } - - return image; -} - -ImageType::Pointer ResampleToMatch(typename ImageType::Pointer referenceImage, - typename ImageType::Pointer inputImage) { - using ResampleFilterType = itk::ResampleImageFilter; - typename ResampleFilterType::Pointer resampleFilter = - ResampleFilterType::New(); - - resampleFilter->SetInput(inputImage); - resampleFilter->SetSize(referenceImage->GetLargestPossibleRegion().GetSize()); - resampleFilter->SetOutputSpacing(referenceImage->GetSpacing()); - resampleFilter->SetOutputOrigin(referenceImage->GetOrigin()); - resampleFilter->SetOutputDirection(referenceImage->GetDirection()); - - using InterpolatorType = - itk::LinearInterpolateImageFunction; - typename InterpolatorType::Pointer interpolator = InterpolatorType::New(); - resampleFilter->SetInterpolator(interpolator); - - using TransformType = itk::AffineTransform; - typename TransformType::Pointer transform = TransformType::New(); - transform->SetIdentity(); - resampleFilter->SetTransform(transform); - - resampleFilter->Update(); - return resampleFilter->GetOutput(); -} - -void SaveImage(ImageType::Pointer image, const std::string& filename) { - using WriterType = itk::ImageFileWriter; - WriterType::Pointer writer = WriterType::New(); - writer->SetFileName(filename); - writer->SetInput(image); - writer->Update(); -} - -ImageType::Pointer LoadNii(const std::string& filename) { - ReaderType::Pointer reader = ReaderType::New(); - reader->SetFileName(filename); - reader->Update(); - return reader->GetOutput(); -} - -void ExtractImageData(ImageType::Pointer image, std::vector& imageData) { - ImageType::RegionType region = image->GetLargestPossibleRegion(); - ImageType::SizeType size = region.GetSize(); - - imageData.resize(size[0] * size[1] * size[2]); - for (size_t x = 0; x < size[0]; ++x) { - for (size_t y = 0; y < size[1]; ++y) { - for (size_t z = 0; z < size[2]; ++z) { - ImageType::IndexType index; - index[0] = static_cast(x); - index[1] = static_cast(y); - index[2] = static_cast(z); - float pixelValue = image->GetPixel(index); - imageData[x * size[1] * size[2] + y * size[2] + z] = pixelValue; - } - } - } -} - -std::string addSuffixToFilePath(const std::string& filePath, - const std::string& suffix) { - std::filesystem::path path(filePath); - std::string stem = path.stem().string(); - std::string extension = path.extension().string(); - std::string parentPath = path.parent_path().string(); - - std::string newFilePath = parentPath + "/" + stem + suffix + extension; - return newFilePath; -} - -void debugLog(const std::string& message) { std::cout << message << std::endl; } - -std::string toLower(const std::string& str) { - std::string lowerStr = str; - std::transform(lowerStr.begin(), lowerStr.end(), lowerStr.begin(), ::tolower); - return lowerStr; -} -} // namespace Common diff --git a/localizer/src/utils/common.h b/localizer/src/utils/common.h deleted file mode 100644 index 449a16b..0000000 --- a/localizer/src/utils/common.h +++ /dev/null @@ -1,40 +0,0 @@ -#pragma once -#ifndef COMMON_H -#define COMMON_H -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -using ImageType = itk::Image; -using DDFType = itk::Image, 3>; -using BinaryImageType = itk::Image; -using ReaderType = itk::ImageFileReader; - -namespace Common { - -void SaveImage(ImageType::Pointer image, const std::string& filename); -ImageType::Pointer LoadNii(const std::string& filename); -void DivideVoxelsByValue(ImageType::Pointer image, float divisor); -double CalculateMeanInMask(ImageType::Pointer image, ImageType::Pointer mask); -ImageType::Pointer ResampleToMatch(typename ImageType::Pointer referenceImage, - typename ImageType::Pointer inputImage); -ImageType::Pointer CreateImageFromVector(const std::vector& imageData, - ImageType::SizeType size); -void ExtractImageData(ImageType::Pointer image, std::vector& imageData); -std::string addSuffixToFilePath(const std::string& filePath, - const std::string& suffix); -void debugLog(const std::string& message); -std::string toLower(const std::string& str); -std::string getExecutablePath(); -} // namespace Common - -#endif diff --git a/localizer/src/utils/onnx_path_utils.h b/localizer/src/utils/onnx_path_utils.h deleted file mode 100644 index ffbe27e..0000000 --- a/localizer/src/utils/onnx_path_utils.h +++ /dev/null @@ -1,15 +0,0 @@ -#pragma once - -#include -#include "onnxruntime_cxx_api.h" - -namespace OrtUtils { -inline std::basic_string MakeOrtPath(const std::string& path) { -#ifdef _WIN32 - return std::wstring(path.begin(), path.end()); -#else - return path; -#endif -} -} // namespace OrtUtils - diff --git a/scripts/docker-build-core.sh b/scripts/docker-build-core.sh new file mode 100755 index 0000000..d120a53 --- /dev/null +++ b/scripts/docker-build-core.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/../localizer/src" + +BUILD_TYPE="${BUILD_TYPE:-Release}" +INSTALL_PREFIX="${INSTALL_PREFIX:-./install}" +CONAN_CPPSTD="${CONAN_CPPSTD:-gnu17}" +CONAN_BUILD_ARGS="${CONAN_BUILD_ARGS:---build=missing}" +# Split the simple, space-delimited Conan build policy string into argv items. +# This lets the legacy Linux path force known build tools such as b2 to be +# rebuilt inside the manylinux2014 container instead of downloading binaries +# that may have been produced on newer glibc systems. +read -r -a conan_build_args <<< "${CONAN_BUILD_ARGS}" + +sudo mkdir -p build "${CONAN_HOME:-/home/dev/.conan2}" +sudo chown -R "$(id -u):$(id -g)" build "${CONAN_HOME:-/home/dev/.conan2}" + +conan profile detect --force +conan install . \ + --output-folder=build \ + "${conan_build_args[@]}" \ + -s build_type="${BUILD_TYPE}" \ + -s compiler.cppstd="${CONAN_CPPSTD}" \ + -c tools.cmake.cmaketoolchain:generator=Ninja \ + -o onetbb/*:tbbmalloc=False \ + -o onetbb/*:tbbproxy=False + +cmake -S . \ + -B build \ + -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE=build/conan_toolchain.cmake \ + -DCMAKE_BUILD_TYPE="${BUILD_TYPE}" \ + -DCMAKE_INSTALL_PREFIX="${INSTALL_PREFIX}" + +cmake --build build --config "${BUILD_TYPE}" +cmake --install build --config "${BUILD_TYPE}"