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).
@@ -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 `