From 7b9ff54dc0b9356bf12132c36bacaf9b92bc7bc7 Mon Sep 17 00:00:00 2001 From: Ali Khan Date: Fri, 23 Oct 2026 16:22:14 -0400 Subject: [PATCH 1/6] Add cohort template building and export analysis levels - cohort_template: build a multivariate (all stains) cohort template with FireANTs from subjects registered to --template, optionally pooling other SPIMquant output dirs; register the original template anat to the cohort template (greedy affine+deformable) and warp its atlases and mask into cohort template space, with a regqc report. - cohort_template_export: write template_description.json, a SPIMquant templates: config snippet and buildinfo tarball, and optionally upload to a Zenodo deposition via snakemake-storage-plugin-zenodo. - FireANTs runs in its own conda env (envs/fireants.yaml) since its PyPI pins conflict with SPIMquant's simpleitk. - pixi.lock regenerated (also upgrades lock format v6 -> v7). Co-Authored-By: Claude Opus 5.5 --- docs/howto/cohort_template.md | 97 +++ mkdocs.yml | 1 + pixi.lock | 26 +- pyproject.toml | 1 + spimquant/config/snakebids.yml | 107 +++ spimquant/workflow/Snakefile | 1 + spimquant/workflow/envs/fireants.yaml | 14 + spimquant/workflow/rules/cohort_template.smk | 657 ++++++++++++++++++ .../scripts/cohort_template_description.py | 114 +++ .../scripts/cohort_template_inputs.py | 52 ++ spimquant/workflow/scripts/reg_qc_report.py | 9 +- 11 files changed, 1071 insertions(+), 8 deletions(-) create mode 100644 docs/howto/cohort_template.md create mode 100644 spimquant/workflow/envs/fireants.yaml create mode 100644 spimquant/workflow/rules/cohort_template.smk create mode 100644 spimquant/workflow/scripts/cohort_template_description.py create mode 100644 spimquant/workflow/scripts/cohort_template_inputs.py diff --git a/docs/howto/cohort_template.md b/docs/howto/cohort_template.md new file mode 100644 index 00000000..af7b25c3 --- /dev/null +++ b/docs/howto/cohort_template.md @@ -0,0 +1,97 @@ +# Cohort templates + +SPIMquant can build a study-specific (cohort) SPIM template from your subjects, +and prepare it for use as a `--template` in later SPIMquant runs. + +The workflow is: + +1. Subjects are registered to `--template` (e.g. ABAv3) as in the `participant` + level, and each stain is resampled to template space at `--cohort-template-level` + (default: `--registration_level`). +2. A multivariate template is built from these images with + [FireANTs](https://github.com/rohitrango/FireANTs) on GPU. All stains are built + jointly, so every stain template is in the same space. +3. The cohort template drifts away from the original template's space during + building, so the original template's anat is registered (affine + deformable, + greedy) to the cohort template. The original atlases, brain mask and anat are + warped into cohort template space with this registration. +4. The `cohort_template_export` level writes a `template_description.json`, a + SPIMquant `templates:` config entry, and optionally uploads everything to Zenodo. + +## Building the template + +``` +spimquant /path/to/bids /path/to/output cohort_template \ + --cohort-template-name lecanemab \ + --cohort-template-stains YoPro Iba1 Abeta \ + --cohort-template-n-gpus 2 +``` + +This runs any registration that has not been run yet, so it can be run on a fresh +output directory or one with existing `participant` outputs. + +Options: + +- `--cohort-template-name`: the `tpl-` label of the new template (alphanumeric). +- `--cohort-template-stains`: stains to build from (default: all). The registration + stain (e.g. YoPro) is always the first template channel, and is the image the + original template is registered to. Stains with strong subject-specific signal + (e.g. Abeta plaques) still contribute to the registration metric, so you may + prefer to restrict this to anatomical stains. +- `--cohort-template-extra-inputs`: other SPIMquant output directories, from + datasets registered to the same `--template`, to pool into the template. Subjects + missing any of the stains are skipped. +- `--cohort-template-iterations`, `--cohort-template-n-gpus`: FireANTs settings. + Other FireANTs parameters are in the `cohort_template:` section of + `config/snakebids.yml`. + +Outputs are in `/cohort_template/`: + +``` +tpl-lecanemab/ +├── tpl-lecanemab_stain-YoPro_SPIM.nii.gz # one per stain +├── tpl-lecanemab_anat.nii.gz # original anat, warped +├── tpl-lecanemab_desc-brain_mask.nii.gz # original mask, warped +├── tpl-lecanemab_seg-_dseg.nii.gz/.tsv # original atlases, warped +├── tpl-lecanemab_from-ABAv3_to-lecanemab_xfm.nii.gz +├── tpl-lecanemab_from-lecanemab_to-ABAv3_xfm.nii.gz +└── tpl-lecanemab_stain-YoPro_from-ABAv3_to-lecanemab_regqc.html +tpl-ABAv3/ +└── tpl-ABAv3_desc-lecanemab_stain-YoPro_SPIM.nii.gz # cohort template in ABAv3 space +``` + +Check the `regqc.html` report to confirm the atlases line up with the cohort template. + +The FireANTs step runs in its own conda environment (`workflow/envs/fireants.yaml`), +and uses the fused CUDA ops if they are installed, falling back to the plain +implementation otherwise. + +## Preparing the template for SPIMquant + +``` +spimquant /path/to/bids /path/to/output cohort_template_export \ + --cohort-template-name lecanemab \ + --cohort-template-stains YoPro Iba1 Abeta +``` + +Use the same `cohort_template` options as when building. This writes +`tpl-lecanemab/template_description.json`, `tpl-lecanemab_buildinfo.tar.gz` (image +list, FireANTs config and logs), and `tpl-lecanemab_spimquant.yml`, a `templates:` +entry to add to `spimquant/config/snakebids.yml` (also add the name to the +`--template` choices). + +To upload to Zenodo, create a draft upload on Zenodo and pass its deposition ID, +with a Zenodo access token in the environment: + +``` +export SNAKEMAKE_STORAGE_ZENODO_ACCESS_TOKEN= +spimquant /path/to/bids /path/to/output cohort_template_export \ + --cohort-template-name lecanemab \ + --cohort-template-stains YoPro Iba1 Abeta \ + --cohort-template-zenodo-deposition 12345678 +``` + +The config entry then points at `https://zenodo.org/records//files/...` URLs, +which resolve once you publish the deposition. Without a deposition, it points at +`tpl-lecanemab/...` paths relative to `spimquant/resources/`, where you can copy the +`tpl-lecanemab` directory instead. diff --git a/mkdocs.yml b/mkdocs.yml index 58a85848..d7db6dce 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -23,6 +23,7 @@ nav: - Segmentation Methods: howto/segmentation.md - MRI Registration: howto/mri_registration.md - Reusing Previous Outputs: howto/reuse_derivatives.md + - Cohort Templates: howto/cohort_template.md - Reference: - CLI Reference: reference/cli_reference.md - Output Files: reference/outputs.md diff --git a/pixi.lock b/pixi.lock index c7279c14..1f404a84 100644 --- a/pixi.lock +++ b/pixi.lock @@ -38,6 +38,7 @@ environments: - conda: https://conda.anaconda.org/bioconda/noarch/snakemake-storage-plugin-gcs-1.1.4-pyhdfd78af_0.tar.bz2 - conda: https://conda.anaconda.org/bioconda/noarch/snakemake-storage-plugin-http-0.3.1-pyhdfd78af_0.conda - conda: https://conda.anaconda.org/bioconda/noarch/snakemake-storage-plugin-s3-0.3.6-pyhdfd78af_0.conda + - conda: https://conda.anaconda.org/bioconda/noarch/snakemake-storage-plugin-zenodo-0.1.5-pyhdfd78af_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-8_kmp_llvm.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/aiohttp-3.14.5-py312h8b7ec3f_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/alsa-lib-1.2.16.1-h7cc23a3_1.conda @@ -638,7 +639,7 @@ environments: - conda: https://prefix.dev/akhanf/khanlab/noarch/ngff-zarr-0.34.0-pyh4616a5c_0.conda - conda: https://prefix.dev/akhanf/khanlab/noarch/vesselfm-0.2.1-pyh4616a5c_0.conda - conda: https://prefix.dev/akhanf/khanlab/noarch/zarrnii-0.21.6-pyh4616a5c_0.conda - - conda_source: spimquant[1ae1793f] @ . + - conda_source: spimquant[a8ed165f] @ . dev: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -663,6 +664,7 @@ environments: - conda: https://conda.anaconda.org/bioconda/noarch/snakemake-storage-plugin-gcs-1.1.4-pyhdfd78af_0.tar.bz2 - conda: https://conda.anaconda.org/bioconda/noarch/snakemake-storage-plugin-http-0.3.1-pyhdfd78af_0.conda - conda: https://conda.anaconda.org/bioconda/noarch/snakemake-storage-plugin-s3-0.3.6-pyhdfd78af_0.conda + - conda: https://conda.anaconda.org/bioconda/noarch/snakemake-storage-plugin-zenodo-0.1.5-pyhdfd78af_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-8_kmp_llvm.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/aiohttp-3.14.5-py312h8b7ec3f_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/alsa-lib-1.2.16.1-h7cc23a3_1.conda @@ -1421,7 +1423,7 @@ environments: - conda: https://prefix.dev/akhanf/khanlab/noarch/ngff-zarr-0.34.0-pyh4616a5c_0.conda - conda: https://prefix.dev/akhanf/khanlab/noarch/vesselfm-0.2.1-pyh4616a5c_0.conda - conda: https://prefix.dev/akhanf/khanlab/noarch/zarrnii-0.21.6-pyh4616a5c_0.conda - - conda_source: spimquant[1ae1793f] @ . + - conda_source: spimquant[a8ed165f] @ . dev-only: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -1605,6 +1607,7 @@ environments: - conda: https://conda.anaconda.org/bioconda/noarch/snakemake-storage-plugin-gcs-1.1.4-pyhdfd78af_0.tar.bz2 - conda: https://conda.anaconda.org/bioconda/noarch/snakemake-storage-plugin-http-0.3.1-pyhdfd78af_0.conda - conda: https://conda.anaconda.org/bioconda/noarch/snakemake-storage-plugin-s3-0.3.6-pyhdfd78af_0.conda + - conda: https://conda.anaconda.org/bioconda/noarch/snakemake-storage-plugin-zenodo-0.1.5-pyhdfd78af_0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-8_kmp_llvm.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/aiohttp-3.14.5-py312h8b7ec3f_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/alsa-lib-1.2.16.1-h7cc23a3_1.conda @@ -2234,7 +2237,7 @@ environments: - conda: https://prefix.dev/akhanf/khanlab/noarch/ngff-zarr-0.34.0-pyh4616a5c_0.conda - conda: https://prefix.dev/akhanf/khanlab/noarch/vesselfm-0.2.1-pyh4616a5c_0.conda - conda: https://prefix.dev/akhanf/khanlab/noarch/zarrnii-0.21.6-pyh4616a5c_0.conda - - conda_source: spimquant[1ae1793f] @ . + - conda_source: spimquant[a8ed165f] @ . packages: - conda: https://conda.anaconda.org/bioconda/noarch/snakebids-0.15.0-pyhdfd78af_0.conda sha256: 20f87c7eb7de89801639e5a700154eb4a456e4fc0445dd4828b2bb57021b575b @@ -2542,6 +2545,20 @@ packages: - snakemake-storage-plugin-s3 >=0.3.6,<0.4.0a0 size: 15758 timestamp: 1757816089665 +- conda: https://conda.anaconda.org/bioconda/noarch/snakemake-storage-plugin-zenodo-0.1.5-pyhdfd78af_0.tar.bz2 + sha256: e7617f57e5adb91e1c3423bfaeffb43cb74c4fbdf9b9facc0446c52d7ed64648 + md5: c1173195d2bc1fe9bd4546cba3f49624 + depends: + - python >=3.11.0,<4.0.0 + - requests >=2.31.0,<3.0.0 + - snakemake-interface-common >=1.14.4,<2.0.0 + - snakemake-interface-storage-plugins >=4.1.0,<5.0.0 + license: MIT + run_exports: + weak: + - snakemake-storage-plugin-zenodo >=0.1.5,<0.2.0a0 + size: 15014 + timestamp: 1743065569614 - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda build_number: 20 sha256: 1dd3fffd892081df9726d7eb7e0dea6198962ba775bd88842135a4ddb4deb3c9 @@ -14807,7 +14824,7 @@ packages: run_exports: {} size: 127748 timestamp: 1792392976566 -- conda_source: spimquant[1ae1793f] @ . +- conda_source: spimquant[a8ed165f] @ . variants: target_platform: noarch depends: @@ -14817,6 +14834,7 @@ packages: - snakemake-storage-plugin-gcs >=1.1.4,<2 - snakemake-storage-plugin-fs >=1.1.2,<2 - snakemake-storage-plugin-cached-http >=0.3.0,<0.4.0 + - snakemake-storage-plugin-zenodo >=0.1.5,<0.2 - snakemake-executor-plugin-slurm >=1.4.0,<2 - conda >=25.5.0,<26 - gcsfs >=2025.5.1,<2026 diff --git a/pyproject.toml b/pyproject.toml index 1bab8a31..49912275 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,7 @@ snakemake-storage-plugin-s3 = ">=0.3.3,<0.4" snakemake-storage-plugin-gcs = ">=1.1.4,<2" snakemake-storage-plugin-fs = ">=1.1.2,<2" snakemake-storage-plugin-cached-http = ">=0.3.0,<0.4.0" +snakemake-storage-plugin-zenodo = ">=0.1.5,<0.2" snakemake-executor-plugin-slurm = ">=1.4.0,<2" conda = ">=25.5.0,<26" gcsfs = ">=2025.5.1,<2026" diff --git a/spimquant/config/snakebids.yml b/spimquant/config/snakebids.yml index 8a23fe95..0b38a00b 100644 --- a/spimquant/config/snakebids.yml +++ b/spimquant/config/snakebids.yml @@ -3,6 +3,8 @@ analysis_levels: &analysis_levels - participant - group - tune_n4 + - cohort_template + - cohort_template_export #mapping from analysis_level to set of target rules or files targets_by_analysis_level: @@ -12,6 +14,10 @@ targets_by_analysis_level: - 'all_group' tune_n4: - 'all_n4_tune' + cohort_template: + - 'all_cohort_template' + cohort_template_export: + - 'all_cohort_template_export' # Configure components: # Each entry creates a new component that can be retreived within the workflow @@ -477,6 +483,40 @@ parse_args: help: "Label for this group-stats run. Outputs are placed under group/{label}/ so that different analyses (e.g. different models or --group-stats-where filters) do not overwrite each other. (default: %(default)s)" default: "1" + --cohort-template-name: + help: "Name (tpl- label) for the cohort template built by the cohort_template analysis level. Must be alphanumeric and must not clash with an existing --template choice. (default: %(default)s)" + default: cohort + + --cohort-template-stains: + help: "Stains to build the cohort template from. All stains are built jointly as one multivariate template, so every stain template shares the same space. (default: all stains)" + default: null + nargs: '+' + + --cohort-template-level: + help: "Downsampling level of the subject images (already warped to --template space) used to build the cohort template (default: --registration_level)" + default: null + type: int + + --cohort-template-iterations: + help: "Number of FireANTs template-building iterations (default: %(default)s)" + default: 10 + type: int + + --cohort-template-n-gpus: + help: "Number of GPUs for FireANTs template building; subjects are split across GPUs (default: %(default)s)" + default: 1 + type: int + + --cohort-template-extra-inputs: + help: "Additional SPIMquant output directories (from other datasets registered to the same --template) whose subjects are added to the cohort template. Subject images are found by globbing for the same space/level/stain files in each directory." + default: null + nargs: '+' + + --cohort-template-zenodo-deposition: + help: "Zenodo deposition ID (an unpublished draft you created) to upload the exported cohort template to, in the cohort_template_export analysis level. Requires SNAKEMAKE_STORAGE_ZENODO_ACCESS_TOKEN to be set. When absent, the export is only prepared locally. (default: %(default)s)" + default: null + type: int + #--- workflow specific configuration -- @@ -588,6 +628,73 @@ masking: zarrnii_out_kwargs: storage_options: {} +cohort_template: + # subject images are clipped to these percentiles (of nonzero voxels) and + # rescaled to [0,1] before template building, so bright outliers (e.g. + # plaques) do not dominate FireANTs' min-max normalization + clip_percentiles: [0.5, 99.5] + # registration of the original template (moving) to the cohort template (fixed) + reg: + metric: NMI + affine_iters: 100x50x0 + deform_iters: 100x50x0 + sigma1: 4vox + sigma2: 2vox + # FireANTs build_template config (image_list_file, save_dir and + # template_iterations are filled in by the workflow) + fireants: + debug: False + verbose: False + image_prefix: null + image_suffix: null + num_subjects: null + init_template_path: null # average of the subjects (already in --template space) + normalize_images: True + save_as_uint8: False + num_laplacian: 2 + laplace_params: + learning_rate: 0.5 + itk_scale: true + shape_avg: true + save_every: 1 + save_init_template: true + save_moved_images: true + save_additional: + image_file: [] + image_prefix: [] + image_suffix: [] + is_segmentation: [] + batch_size: 1 + do_moments: False + moments: + scale: 4 + moments: 1 + do_rigid: False + rigid: + iterations: [200, 100, 25] + scales: [4, 2, 1] + progress_bar: False + loss_type: cc + do_affine: False + affine: + iterations: [200, 100, 25] + scales: [4, 2, 1] + progress_bar: False + loss_type: cc + do_deform: True + deform_algo: greedy + deform: + iterations: [200, 100, 50] + scales: [4, 2, 1] + optimizer_lr: 0.25 + smooth_warp_sigma: 0.5 + smooth_grad_sigma: 1.0 + progress_bar: False + cc_kernel_size: 5 + loss_type: fusedcc # falls back to cc if fireants fused_ops are not built + tmpdir: null + last_reg: null + reg_mri: rigidgreedy: iters: 100x100x50 diff --git a/spimquant/workflow/Snakefile b/spimquant/workflow/Snakefile index a85fa179..b7fc3f77 100644 --- a/spimquant/workflow/Snakefile +++ b/spimquant/workflow/Snakefile @@ -1520,6 +1520,7 @@ include: "rules/patches.smk" include: "rules/groupstats.smk" include: "rules/qc.smk" include: "rules/sidecars.smk" +include: "rules/cohort_template.smk" if config["register_to_mri"]: diff --git a/spimquant/workflow/envs/fireants.yaml b/spimquant/workflow/envs/fireants.yaml new file mode 100644 index 00000000..91d53430 --- /dev/null +++ b/spimquant/workflow/envs/fireants.yaml @@ -0,0 +1,14 @@ +name: fireants +channels: + - conda-forge +dependencies: + - python=3.11 + - pip + - pip: + # PyPI torch wheels bundle the CUDA runtime, so the env can be created on a + # node without a GPU. FireANTs pins simpleitk==2.2.1, which is why it lives in + # its own env instead of the main pixi environment. + - torch==2.5.1 + # khanlab fork; multi-stain templates need the per-channel template saving + # in build_template.py (template_{epoch}_ch-{c}.nii.gz) + - fireants @ git+https://github.com/khanlab/FireANTs@f551add7a2467cb2130009b54a59b8a96b3cab7c diff --git a/spimquant/workflow/rules/cohort_template.smk b/spimquant/workflow/rules/cohort_template.smk new file mode 100644 index 00000000..b597452b --- /dev/null +++ b/spimquant/workflow/rules/cohort_template.smk @@ -0,0 +1,657 @@ +""" +Cohort template building workflow for SPIMquant. + +Builds a study-specific (cohort) SPIM template from subjects that have already +been registered to --template, then registers the original template to the new +one so its atlases can be carried over into cohort template space. + +Key workflow stages: +1. Gather each subject's per-stain images in --template space (this dataset, plus + any --cohort-template-extra-inputs SPIMquant output directories) +2. Robust intensity normalization of the subject images +3. Multivariate template building with FireANTs (all stains share one space) +4. Registration of the original template anat (moving) to the cohort template + (fixed), since the cohort template drifts away from the original template + space during building +5. Warping of the original atlases, brain mask and anat into cohort template space +6. Export: template_description.json, a templates: config snippet for SPIMquant, + and optional upload to a Zenodo deposition + +Invoke with: +``spimquant cohort_template`` and then +``spimquant cohort_template_export`` +""" + +import glob as _glob + +cohort_name = config["cohort_template_name"] +orig_template = config["template"] +cohort_level = ( + config["cohort_template_level"] + if config["cohort_template_level"] is not None + else config["registration_level"] +) +cohort_iterations = config["cohort_template_iterations"] +cohort_n_gpus = config["cohort_template_n_gpus"] +cohort_zenodo_deposition = config["cohort_template_zenodo_deposition"] + +# final outputs in cohort_root/tpl-{name}/, intermediate files in cohort_work +cohort_root = os.path.join(root, "cohort_template") +cohort_work = os.path.join(work, "cohort_template", f"work-{cohort_name}") + +# the registration stain is channel 0 of the multivariate template: it is the +# fixed image for the original-to-cohort template registration +_cohort_stains_req = config["cohort_template_stains"] or stains +cohort_stains = [stain_for_reg] if stain_for_reg in _cohort_stains_req else [] +cohort_stains += [s for s in _cohort_stains_req if s not in cohort_stains] +cohort_reg_stain = cohort_stains[0] + +# atlases carried over from the original template (all of them, so the cohort +# template supports every --atlas_segs choice the original did) +cohort_atlas_segs = list(template.get("atlases", {}).keys()) +cohort_has_mask = "mask" in template + +# snakebids paths always use the bids() entity order, so the filename tail is +# the same in other SPIMquant output directories +_cohort_input_pattern = bids( + root=root, + datatype="micr", + stain="{stain}", + level="{level}", + space="{template}", + suffix="SPIM.nii.gz", + **inputs["spim"].wildcards, +) + + +def _cohort_row_name(path, stain): + """Subject identifier: the filename entities that precede stain-.""" + return os.path.basename(path).split(f"_stain-{stain}")[0] + + +def _get_cohort_rows(): + """One row per subject: name and per-stain image paths (cohort_stains order).""" + rows = [] + per_stain = [ + inputs["spim"].expand( + _cohort_input_pattern, + stain=s, + level=cohort_level, + template=orig_template, + ) + for s in cohort_stains + ] + for images in zip(*per_stain): + rows.append( + { + "name": _cohort_row_name(images[0], cohort_stains[0]), + "images": list(images), + } + ) + + for extra_dir in config["cohort_template_extra_inputs"] or []: + by_name = {} + for s in cohort_stains: + tail = os.path.basename( + bids( + stain=s, + level=cohort_level, + space=orig_template, + suffix="SPIM.nii.gz", + ) + ) + for path in sorted( + _glob.glob( + os.path.join(extra_dir, "sub-*", "**", f"*_{tail}"), recursive=True + ) + ): + by_name.setdefault(_cohort_row_name(path, s), {})[s] = path + if not by_name: + raise ValueError( + f"--cohort-template-extra-inputs: no space-{orig_template} " + f"level-{cohort_level} SPIM images found in {extra_dir}" + ) + for name, images in sorted(by_name.items()): + missing = [s for s in cohort_stains if s not in images] + if missing: + print( + f"[cohort_template] skipping {name} from {extra_dir}: " + f"missing stain(s) {missing}" + ) + continue + rows.append({"name": name, "images": [images[s] for s in cohort_stains]}) + + # subject IDs can repeat across datasets + seen = {} + for row in rows: + n = seen.get(row["name"], 0) + seen[row["name"]] = n + 1 + if n > 0: + row["name"] = f"{row['name']}-dup{n}" + return rows + + +if config.get("analysis_level") in ("cohort_template", "cohort_template_export"): + if not cohort_name.isalnum(): + raise ValueError( + f"--cohort-template-name must be alphanumeric, got {repr(cohort_name)}" + ) + if cohort_name in config["templates"]: + raise ValueError( + f"--cohort-template-name {repr(cohort_name)} clashes with an existing template" + ) + _unknown = set(_cohort_stains_req) - set(stains) + if _unknown: + raise ValueError( + f"--cohort-template-stains {sorted(_unknown)} not found in stains {stains}" + ) + cohort_rows = _get_cohort_rows() + if len(cohort_rows) < 2: + raise ValueError( + f"Need at least 2 subjects to build a cohort template, found {len(cohort_rows)}" + ) + print( + f"[cohort_template] building tpl-{cohort_name} from {len(cohort_rows)} subjects, " + f"stains {cohort_stains}, in {orig_template} space at level {cohort_level}" + ) +else: + cohort_rows = [] + + +def bids_cohort(**kwargs): + """bids() path for a final cohort template file (cohort_root/tpl-{name}/).""" + return bids(root=cohort_root, template=cohort_name, **kwargs) + + +def bids_orig(root=root, **kwargs): + """bids() path for an original template file (imported ones by default).""" + return bids(root=root, template=orig_template, **kwargs) + + +cohort_description = os.path.join( + cohort_root, f"tpl-{cohort_name}", "template_description.json" +) +cohort_config_snippet = os.path.join(cohort_root, f"tpl-{cohort_name}_spimquant.yml") +cohort_buildinfo = os.path.join(cohort_root, f"tpl-{cohort_name}_buildinfo.tar.gz") + + +rule all_cohort_template: + input: + expand(bids_cohort(stain="{stain}", suffix="SPIM.nii.gz"), stain=cohort_stains), + bids_cohort(suffix="anat.nii.gz"), + bids_cohort(desc="brain", suffix="mask.nii.gz") if cohort_has_mask else [], + expand( + bids_cohort(seg="{seg}", suffix="dseg.{ext}"), + seg=cohort_atlas_segs, + ext=["nii.gz", "tsv"], + ), + bids_cohort(from_=orig_template, to=cohort_name, suffix="xfm.nii.gz"), + bids_cohort(from_=cohort_name, to=orig_template, suffix="xfm.nii.gz"), + bids_cohort( + stain=cohort_reg_stain, + from_=orig_template, + to=cohort_name, + suffix="regqc.html", + ), + # cohort template resampled back into the original template space + expand( + bids_orig( + root=cohort_root, + desc=cohort_name, + stain="{stain}", + suffix="SPIM.nii.gz", + ), + stain=cohort_stains, + ), + + +rule cohort_template_inputs: + """Normalize subject images and write the FireANTs image list and config. + + Each image is clipped to robust percentiles of its nonzero voxels and + rescaled to [0,1]. The image list has one comma-separated row per subject + (one entry per stain), which FireANTs builds as one multivariate template. + """ + input: + images=lambda wildcards: [img for row in cohort_rows for img in row["images"]], + output: + images=temp(directory(os.path.join(cohort_work, "inputs"))), + image_list=os.path.join(cohort_work, "images.txt"), + fireants_config=os.path.join(cohort_work, "config", "cohort.yaml"), + threads: 1 + resources: + mem_mb=16000, + runtime=lambda wildcards, input: 10 + len(input.images), + params: + rows=cohort_rows, + stains=cohort_stains, + clip_percentiles=config["cohort_template"]["clip_percentiles"], + fireants=lambda wildcards, output: { + **config["cohort_template"]["fireants"], + "template_iterations": cohort_iterations, + "image_list_file": os.path.abspath(output.image_list), + "save_dir": os.path.abspath( + os.path.join(cohort_work, "fireants", "buildtemplate") + ), + }, + script: + "../scripts/cohort_template_inputs.py" + + +rule build_cohort_template: + """Build the multivariate cohort template with FireANTs (GPU).""" + input: + images=rules.cohort_template_inputs.output.images, + image_list=rules.cohort_template_inputs.output.image_list, + fireants_config=rules.cohort_template_inputs.output.fireants_config, + output: + build_dir=directory(os.path.join(cohort_work, "fireants", "buildtemplate")), + log: + os.path.join(cohort_work, "logs", "build_cohort_template.log"), + conda: + "../envs/fireants.yaml" + threads: 8 * cohort_n_gpus + resources: + gpu=cohort_n_gpus, + # one task that launches one process per GPU via torchrun (see plaques.smk) + tasks_per_gpu=0, + cpus_per_gpu=8, + mem_mb=64000, + # rough estimate: ~30s per subject per iteration, split across GPUs + runtime=int(15 + 0.5 * len(cohort_rows) * cohort_iterations / cohort_n_gpus), + params: + config_dir=lambda wildcards, input: os.path.abspath( + os.path.dirname(input.fireants_config) + ), + config_name="cohort", + hydra_dir=lambda wildcards, output: os.path.abspath( + os.path.join(output.build_dir, "..", "hydra") + ), + shell: + "torchrun --standalone --nproc_per_node {resources.gpu} " + " -m fireants.scripts.template.build_template " + " --config-dir {params.config_dir} --config-name {params.config_name} " + " hydra.run.dir={params.hydra_dir} &> {log}" + + +rule cohort_template_stain: + """Extract the final-iteration template for one stain (channel).""" + input: + build_dir=rules.build_cohort_template.output.build_dir, + output: + spim=bids_cohort(stain="{stain}", suffix="SPIM.nii.gz"), + threads: 1 + resources: + mem_mb=1500, + runtime=15, + params: + template_nii=lambda wildcards, input: os.path.join( + input.build_dir, + f"template_{cohort_iterations-1}" + + ( + f"_ch-{cohort_stains.index(wildcards.stain)}" + if len(cohort_stains) > 1 + else "" + ) + + ".nii.gz", + ), + shell: + "if [ ! -f {params.template_nii} ]; then " + " echo 'Missing {params.template_nii}: multi-stain cohort templates need a " + "FireANTs build_template that saves every channel (template__ch-)' >&2;" + " exit 1; fi; " + "cp {params.template_nii} {output.spim}" + + +rule cohort_template_affine_reg: + """Affine registration of the original template anat to the cohort template. + + The images start out roughly aligned (subjects were registered to the + original template), so registration is initialized with identity. + """ + input: + fixed=bids_cohort(stain=cohort_reg_stain, suffix="SPIM.nii.gz"), + moving=bids_orig(suffix="anat.nii.gz"), + output: + xfm_ras=os.path.join(cohort_work, "xfm", "affine_ras_xfm.txt"), + warped=os.path.join(cohort_work, "xfm", "affinewarped_anat.nii.gz"), + log: + os.path.join(cohort_work, "logs", "cohort_template_affine_reg.log"), + threads: 32 + resources: + mem_mb=16000, + runtime=30, + params: + metric=config["cohort_template"]["reg"]["metric"], + iters=( + "10x0x0" + if config["sloppy"] + else config["cohort_template"]["reg"]["affine_iters"] + ), + shell: + "(greedy -threads {threads} -d 3 -i {input.fixed} {input.moving} " + " -a -dof 12 -ia-identity -m {params.metric} -o {output.xfm_ras} -n {params.iters} && " + " greedy -threads {threads} -d 3 -rf {input.fixed} " + " -rm {input.moving} {output.warped} -r {output.xfm_ras}) &> {log}" + + +rule cohort_template_deform_reg: + """Deformable registration of the original template anat to the cohort template.""" + input: + fixed=bids_cohort(stain=cohort_reg_stain, suffix="SPIM.nii.gz"), + moving=bids_orig(suffix="anat.nii.gz"), + xfm_ras=rules.cohort_template_affine_reg.output.xfm_ras, + output: + warp=os.path.join(cohort_work, "xfm", "warp.nii.gz"), + invwarp=os.path.join(cohort_work, "xfm", "invwarp.nii.gz"), + warped=os.path.join(cohort_work, "xfm", "deformwarped_anat.nii.gz"), + log: + os.path.join(cohort_work, "logs", "cohort_template_deform_reg.log"), + threads: 32 + resources: + mem_mb=16000, + runtime=5 if config["sloppy"] else 60, + params: + metric=config["cohort_template"]["reg"]["metric"], + iters=( + "10x0x0" + if config["sloppy"] + else config["cohort_template"]["reg"]["deform_iters"] + ), + sigma1=config["cohort_template"]["reg"]["sigma1"], + sigma2=config["cohort_template"]["reg"]["sigma2"], + shell: + "(greedy -threads {threads} -d 3 -i {input.fixed} {input.moving} " + " -it {input.xfm_ras} -m {params.metric} " + " -oinv {output.invwarp} " + " -o {output.warp} -n {params.iters} -s {params.sigma1} {params.sigma2} && " + " greedy -threads {threads} -d 3 -rf {input.fixed} " + " -rm {input.moving} {output.warped} " + " -r {output.warp} {input.xfm_ras}) &> {log}" + + +rule cohort_template_ras_to_itk: + input: + xfm_ras=rules.cohort_template_affine_reg.output.xfm_ras, + output: + xfm_itk=os.path.join(cohort_work, "xfm", "affine_itk_xfm.txt"), + conda: + "../envs/c3d.yaml" + threads: 1 + resources: + mem_mb=1500, + runtime=15, + shell: + "c3d_affine_tool {input.xfm_ras} -oitk {output.xfm_itk}" + + +rule cohort_template_compose_warp: + """Composite warps between the original and cohort template spaces. + + from-{orig}_to-{cohort} resamples original template images into cohort + template space (and maps cohort-space points to the original space); + from-{cohort}_to-{orig} is its inverse. + """ + input: + cohort_ref=bids_cohort(stain=cohort_reg_stain, suffix="SPIM.nii.gz"), + orig_ref=bids_orig(suffix="anat.nii.gz"), + xfm_itk=rules.cohort_template_ras_to_itk.output.xfm_itk, + warp=rules.cohort_template_deform_reg.output.warp, + invwarp=rules.cohort_template_deform_reg.output.invwarp, + output: + xfm_composite=bids_cohort( + from_=orig_template, to=cohort_name, suffix="xfm.nii.gz" + ), + xfm_composite_inv=bids_cohort( + from_=cohort_name, to=orig_template, suffix="xfm.nii.gz" + ), + conda: + "../envs/ants.yaml" + threads: 4 + resources: + mem_mb=16000, + runtime=15, + shell: + "ITK_GLOBAL_DEFAULT_NUMBER_OF_THREADS={threads} " + "antsApplyTransforms -d 3 -o [{output.xfm_composite},1] " + " -r {input.cohort_ref} -t {input.warp} {input.xfm_itk} && " + "ITK_GLOBAL_DEFAULT_NUMBER_OF_THREADS={threads} " + "antsApplyTransforms -d 3 -o [{output.xfm_composite_inv},1] " + " -r {input.orig_ref} -t {input.invwarp} [{input.xfm_itk},1]" + + +_cohort_warp_shell = ( + "ITK_GLOBAL_DEFAULT_NUMBER_OF_THREADS={threads} " + "antsApplyTransforms -d 3 -v -n {params.interp} " + " -i {input.img} -o {output} -r {input.ref} -t {input.xfm}" +) + + +rule cohort_template_warp_anat: + input: + img=bids_orig(suffix="anat.nii.gz"), + ref=bids_cohort(stain=cohort_reg_stain, suffix="SPIM.nii.gz"), + xfm=rules.cohort_template_compose_warp.output.xfm_composite, + output: + bids_cohort(suffix="anat.nii.gz"), + conda: + "../envs/ants.yaml" + threads: 8 + resources: + mem_mb=16000, + runtime=15, + params: + interp="Linear", + shell: + _cohort_warp_shell + + +rule cohort_template_warp_mask: + input: + img=bids_orig(desc="brain", suffix="mask.nii.gz"), + ref=bids_cohort(stain=cohort_reg_stain, suffix="SPIM.nii.gz"), + xfm=rules.cohort_template_compose_warp.output.xfm_composite, + output: + bids_cohort(desc="brain", suffix="mask.nii.gz"), + conda: + "../envs/ants.yaml" + threads: 8 + resources: + mem_mb=16000, + runtime=15, + params: + interp="NearestNeighbor", + shell: + _cohort_warp_shell + + +rule cohort_template_warp_dseg: + """Warp an original template atlas into cohort template space.""" + input: + img=bids_orig(seg="{seg}", suffix="dseg.nii.gz"), + ref=bids_cohort(stain=cohort_reg_stain, suffix="SPIM.nii.gz"), + xfm=rules.cohort_template_compose_warp.output.xfm_composite, + output: + bids_cohort(seg="{seg}", suffix="dseg.nii.gz"), + conda: + "../envs/ants.yaml" + threads: 8 + resources: + mem_mb=16000, + runtime=15, + params: + interp="NearestNeighbor", + shell: + _cohort_warp_shell + + +rule cohort_template_copy_dseg_tsv: + input: + bids_orig(seg="{seg}", suffix="dseg.tsv"), + output: + bids_cohort(seg="{seg}", suffix="dseg.tsv"), + localrule: True + shell: + "cp {input} {output}" + + +rule cohort_template_to_orig_space: + """Resample a cohort stain template into the original template space. + + Useful for replacing spim_templates of the original template with ones that + are actually aligned to its atlases. + """ + input: + img=bids_cohort(stain="{stain}", suffix="SPIM.nii.gz"), + ref=bids_orig(suffix="anat.nii.gz"), + xfm=rules.cohort_template_compose_warp.output.xfm_composite_inv, + output: + bids_orig( + root=cohort_root, desc=cohort_name, stain="{stain}", suffix="SPIM.nii.gz" + ), + conda: + "../envs/ants.yaml" + threads: 8 + resources: + mem_mb=16000, + runtime=15, + params: + interp="Linear", + shell: + _cohort_warp_shell + + +rule cohort_template_regqc: + """QC report for the original-to-cohort template registration.""" + input: + template=bids_cohort(stain=cohort_reg_stain, suffix="SPIM.nii.gz"), + subject=bids_orig(suffix="anat.nii.gz"), + warped_affine=rules.cohort_template_affine_reg.output.warped, + warped_deform=rules.cohort_template_deform_reg.output.warped, + warp=rules.cohort_template_compose_warp.output.xfm_composite, + dseg=bids_cohort(seg=list(atlas_segs)[0], suffix="dseg.nii.gz"), + output: + report_html=bids_cohort( + stain=cohort_reg_stain, + from_=orig_template, + to=cohort_name, + suffix="regqc.html", + ), + threads: 1 + resources: + mem_mb=8000, + runtime=30, + params: + subject=f"tpl-{orig_template}", + template=f"tpl-{cohort_name}", + stain=cohort_reg_stain, + script: + "../scripts/reg_qc_report.py" + + +# --- export: prepare the cohort template for inclusion in SPIMquant --- + +# every file a SPIMquant templates: entry for the cohort template refers to +cohort_export_files = ( + [bids_cohort(suffix="anat.nii.gz")] + + ([bids_cohort(desc="brain", suffix="mask.nii.gz")] if cohort_has_mask else []) + + [bids_cohort(stain=s, suffix="SPIM.nii.gz") for s in cohort_stains] + + [ + bids_cohort(seg=seg, suffix=f"dseg.{ext}") + for seg in cohort_atlas_segs + for ext in ["nii.gz", "tsv"] + ] + + [ + bids_cohort(from_=orig_template, to=cohort_name, suffix="xfm.nii.gz"), + bids_cohort(from_=cohort_name, to=orig_template, suffix="xfm.nii.gz"), + ] +) + + +cohort_upload_files = cohort_export_files + [cohort_description, cohort_buildinfo] + + +rule all_cohort_template_export: + input: + rules.all_cohort_template.input, + cohort_description, + cohort_config_snippet, + cohort_buildinfo, + ( + [ + os.path.join(cohort_work, "zenodo", f"{os.path.basename(f)}.uploaded") + for f in cohort_upload_files + ] + if cohort_zenodo_deposition is not None + else [] + ), + + +rule cohort_template_buildinfo: + """Archive of the inputs used to build the template, for provenance.""" + input: + image_list=rules.cohort_template_inputs.output.image_list, + fireants_config=rules.cohort_template_inputs.output.fireants_config, + # logs cannot be inputs; these outputs guarantee the logs were written + build_dir=rules.build_cohort_template.output.build_dir, + warp=rules.cohort_template_deform_reg.output.warp, + output: + cohort_buildinfo, + localrule: True + params: + logs=[ + rules.build_cohort_template.log[0], + rules.cohort_template_affine_reg.log[0], + rules.cohort_template_deform_reg.log[0], + ], + shell: + "tar -czf {output} --transform 's,.*/,,' " + " {input.image_list} {input.fireants_config} {params.logs}" + + +rule cohort_template_description: + """template_description.json and a SPIMquant templates: config snippet.""" + input: + files=cohort_export_files, + image_list=rules.cohort_template_inputs.output.image_list, + output: + description=cohort_description, + config_snippet=cohort_config_snippet, + localrule: True + params: + name=cohort_name, + orig_template=orig_template, + orig_template_config=template, + stains=cohort_stains, + level=cohort_level, + iterations=cohort_iterations, + subjects=[row["name"] for row in cohort_rows], + fireants=config["cohort_template"]["fireants"], + reg=config["cohort_template"]["reg"], + zenodo_deposition=cohort_zenodo_deposition, + template_dir=os.path.join(cohort_root, f"tpl-{cohort_name}"), + script: + "../scripts/cohort_template_description.py" + + +def _cohort_export_file(wildcards): + for f in cohort_upload_files: + if os.path.basename(f) == wildcards.fname: + return f + raise ValueError(f"{wildcards.fname} is not a cohort template export file") + + +if cohort_zenodo_deposition is not None: + + rule upload_cohort_template_zenodo: + """Upload one export file to the (draft) Zenodo deposition.""" + input: + _cohort_export_file, + output: + uploaded=touch(os.path.join(cohort_work, "zenodo", "{fname}.uploaded")), + zenodo=storage(f"zenodo://deposition/{cohort_zenodo_deposition}/{{fname}}"), + wildcard_constraints: + fname="[^/]+", + localrule: True + shell: + "cp {input} {output.zenodo}" diff --git a/spimquant/workflow/scripts/cohort_template_description.py b/spimquant/workflow/scripts/cohort_template_description.py new file mode 100644 index 00000000..cae54372 --- /dev/null +++ b/spimquant/workflow/scripts/cohort_template_description.py @@ -0,0 +1,114 @@ +"""Write template_description.json and a SPIMquant templates: config snippet. + +The snippet points at Zenodo record URLs when a deposition ID was given, and +otherwise at paths relative to spimquant/resources/ (after copying the +tpl-{name} directory there). +""" + +import json +import os +from datetime import date + +import nibabel as nib +import yaml + +p = snakemake.params +name = p.name +orig = p.orig_template_config + +files = {os.path.basename(f): f for f in snakemake.input.files} + + +def ref(fname): + if fname not in files: + raise ValueError(f"{fname} is not one of the exported files") + if p.zenodo_deposition is not None: + return f"https://zenodo.org/records/{p.zenodo_deposition}/files/{fname}" + return f"tpl-{name}/{fname}" + + +anat_fname = f"tpl-{name}_anat.nii.gz" +entry = {"anat": ref(anat_fname)} +if f"tpl-{name}_desc-brain_mask.nii.gz" in files: + entry["mask"] = ref(f"tpl-{name}_desc-brain_mask.nii.gz") +# stain order is the registration priority SPIMquant uses +entry["spim_templates"] = { + stain: ref(f"tpl-{name}_stain-{stain}_SPIM.nii.gz") for stain in p.stains +} +if "default_segs" in orig: + entry["default_segs"] = list(orig["default_segs"]) +entry["atlases"] = { + seg: { + "dseg": ref(f"tpl-{name}_seg-{seg}_dseg.nii.gz"), + "tsv": ref(f"tpl-{name}_seg-{seg}_dseg.tsv"), + } + for seg in orig.get("atlases", {}) +} + +if p.zenodo_deposition is not None: + location = ( + f"# Files are in Zenodo deposition {p.zenodo_deposition}; the URLs resolve\n" + "# once the deposition is published.\n" + ) +else: + location = ( + f"# Paths are relative to spimquant/resources/: copy {p.template_dir}\n" + "# there, or rerun cohort_template_export with\n" + "# --cohort-template-zenodo-deposition to upload to Zenodo.\n" + ) + +with open(snakemake.output.config_snippet, "w") as f: + f.write( + f"# SPIMquant templates: entry for tpl-{name}, a cohort template built from\n" + f"# {len(p.subjects)} subjects registered to tpl-{p.orig_template}, with atlases\n" + f"# warped from tpl-{p.orig_template}.\n" + f"# Add it under templates: in spimquant/config/snakebids.yml, and add {name}\n" + "# to the --template choices.\n" + location + ) + yaml.safe_dump({"templates": {name: entry}}, f, sort_keys=False) + + +anat = nib.load(files[anat_fname]) +description = { + "Name": f"SPIMquant cohort template {name}", + "Identifier": name, + "Species": "Mus musculus", + "DateCreated": date.today().isoformat(), + "GeneratedBy": [ + { + "Name": "SPIMquant", + "Description": "cohort_template analysis level: FireANTs multivariate " + "template building, then greedy registration of the original template " + "to the cohort template to warp its atlases", + "CodeURL": "https://github.com/khanlab/SPIMquant", + }, + { + "Name": "FireANTs", + "CodeURL": "https://github.com/rohitrango/FireANTs", + }, + ], + "OriginalTemplate": p.orig_template, + "Stains": list(p.stains), + "InputLevel": p.level, + "TemplateIterations": p.iterations, + "Subjects": list(p.subjects), + "Atlases": list(entry["atlases"].keys()), + "Transforms": { + f"tpl-{name}_from-{p.orig_template}_to-{name}_xfm.nii.gz": f"resamples " + f"tpl-{p.orig_template} images into tpl-{name} space", + f"tpl-{name}_from-{name}_to-{p.orig_template}_xfm.nii.gz": f"resamples " + f"tpl-{name} images into tpl-{p.orig_template} space", + }, + "FireANTsParameters": dict(p.fireants), + "RegistrationParameters": dict(p.reg), + "res": { + "1": { + "shape": list(anat.shape[:3]), + "zooms": [float(z) for z in anat.header.get_zooms()[:3]], + "origin": [float(o) for o in anat.affine[:3, 3]], + } + }, +} + +with open(snakemake.output.description, "w") as f: + json.dump(description, f, indent=2) diff --git a/spimquant/workflow/scripts/cohort_template_inputs.py b/spimquant/workflow/scripts/cohort_template_inputs.py new file mode 100644 index 00000000..2a0463b3 --- /dev/null +++ b/spimquant/workflow/scripts/cohort_template_inputs.py @@ -0,0 +1,52 @@ +"""Prepare subject images for FireANTs cohort template building. + +Each subject image (already in the original template space) is clipped to +robust percentiles of its nonzero voxels and rescaled to [0, 1]. Writes: + +- the normalized images, one per subject and stain +- an image list with one comma-separated row per subject (one entry per stain, + in the stain order of the template channels) +- the FireANTs build_template config +""" + +import os + +import nibabel as nib +import numpy as np +import yaml + +rows = snakemake.params.rows +stains = snakemake.params.stains +p_lo, p_hi = snakemake.params.clip_percentiles +out_dir = snakemake.output.images + +os.makedirs(out_dir, exist_ok=True) + +image_list_rows = [] +for row in rows: + out_paths = [] + for stain, in_path in zip(stains, row["images"]): + img = nib.load(in_path) + data = np.asarray(img.dataobj, dtype=np.float32) + nonzero = data[data > 0] + if nonzero.size == 0: + raise ValueError(f"{in_path} has no nonzero voxels") + lo, hi = np.percentile(nonzero, [p_lo, p_hi]) + data = (np.clip(data, lo, hi) - lo) / max(hi - lo, np.finfo(np.float32).eps) + + out_path = os.path.abspath( + os.path.join(out_dir, f"{row['name']}_stain-{stain}.nii.gz") + ) + out_img = nib.Nifti1Image(data, img.affine, img.header) + out_img.set_data_dtype(np.float32) + out_img.to_filename(out_path) + out_paths.append(out_path) + print(f"{in_path}: clipped to [{lo:.4g}, {hi:.4g}] -> {out_path}") + image_list_rows.append(",".join(out_paths)) + +with open(snakemake.output.image_list, "w") as f: + f.write("\n".join(image_list_rows) + "\n") + +os.makedirs(os.path.dirname(snakemake.output.fireants_config), exist_ok=True) +with open(snakemake.output.fireants_config, "w") as f: + yaml.safe_dump(dict(snakemake.params.fireants), f, sort_keys=False) diff --git a/spimquant/workflow/scripts/reg_qc_report.py b/spimquant/workflow/scripts/reg_qc_report.py index dc87336c..075040ea 100644 --- a/spimquant/workflow/scripts/reg_qc_report.py +++ b/spimquant/workflow/scripts/reg_qc_report.py @@ -26,10 +26,11 @@ output_html = snakemake.output.report_html -# Get wildcards for report title -subject = snakemake.wildcards.subject -template = snakemake.wildcards.template -stain = snakemake.wildcards.stain +# Get wildcards for report title (params for rules without these wildcards, +# e.g. the original-to-cohort template registration) +subject = getattr(snakemake.wildcards, "subject", None) or snakemake.params.subject +template = getattr(snakemake.wildcards, "template", None) or snakemake.params.template +stain = getattr(snakemake.wildcards, "stain", None) or snakemake.params.stain print( f"Processing registration QC for subject {subject}, stain {stain}, template {template}" From a99da25fa5a564b36e86cf2185f62ef90bea0a80 Mon Sep 17 00:00:00 2001 From: Ali Khan Date: Mon, 19 Oct 2026 03:16:10 -0400 Subject: [PATCH 2/6] Pin FireANTs to khanlab multichannel-save commit Co-Authored-By: Claude Opus 5.5 --- spimquant/workflow/envs/fireants.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spimquant/workflow/envs/fireants.yaml b/spimquant/workflow/envs/fireants.yaml index 91d53430..a08d428b 100644 --- a/spimquant/workflow/envs/fireants.yaml +++ b/spimquant/workflow/envs/fireants.yaml @@ -11,4 +11,4 @@ dependencies: - torch==2.5.1 # khanlab fork; multi-stain templates need the per-channel template saving # in build_template.py (template_{epoch}_ch-{c}.nii.gz) - - fireants @ git+https://github.com/khanlab/FireANTs@f551add7a2467cb2130009b54a59b8a96b3cab7c + - fireants @ git+https://github.com/khanlab/FireANTs@a812cd1b16837ed3c0f62675eaa9d8e67f2a4ee9 From a2f7a87566d50822402c794be6c3d04da0d053b9 Mon Sep 17 00:00:00 2001 From: Ali Khan Date: Fri, 23 Oct 2026 21:44:55 -0400 Subject: [PATCH 3/6] move fireants to pixi dep --- docs/howto/cohort_template.md | 6 +++--- pixi.lock | 21 ++++++++++++++++++++ pyproject.toml | 2 ++ spimquant/workflow/envs/fireants.yaml | 14 ------------- spimquant/workflow/rules/cohort_template.smk | 6 ++++-- 5 files changed, 30 insertions(+), 19 deletions(-) delete mode 100644 spimquant/workflow/envs/fireants.yaml diff --git a/docs/howto/cohort_template.md b/docs/howto/cohort_template.md index af7b25c3..302e1089 100644 --- a/docs/howto/cohort_template.md +++ b/docs/howto/cohort_template.md @@ -62,9 +62,9 @@ tpl-ABAv3/ Check the `regqc.html` report to confirm the atlases line up with the cohort template. -The FireANTs step runs in its own conda environment (`workflow/envs/fireants.yaml`), -and uses the fused CUDA ops if they are installed, falling back to the plain -implementation otherwise. +FireANTs is only installed in the `gpu` pixi environment, so run this level from +it, e.g. `pixi run -e gpu spimquant ...`. It uses the fused CUDA ops if they are +installed, falling back to the plain implementation otherwise. ## Preparing the template for SPIMquant diff --git a/pixi.lock b/pixi.lock index 1f404a84..fe553f7e 100644 --- a/pixi.lock +++ b/pixi.lock @@ -2233,6 +2233,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/zict-3.0.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.1-pyh5ded981_0.conda - conda: https://prefix.dev/akhanf/khanlab/noarch/dynamic-network-architectures-0.4.4-pyh4616a5c_0.conda + - conda: https://prefix.dev/akhanf/khanlab/noarch/fireants-1.4.0.post1-pyh4616a5c_0.conda - conda: https://prefix.dev/akhanf/khanlab/noarch/imaris-ims-zarr-0.2.0-pyh4616a5c_0.conda - conda: https://prefix.dev/akhanf/khanlab/noarch/ngff-zarr-0.34.0-pyh4616a5c_0.conda - conda: https://prefix.dev/akhanf/khanlab/noarch/vesselfm-0.2.1-pyh4616a5c_0.conda @@ -14730,6 +14731,26 @@ packages: run_exports: {} size: 43038 timestamp: 1792390838032 +- conda: https://prefix.dev/akhanf/khanlab/noarch/fireants-1.4.0.post1-pyh4616a5c_0.conda + sha256: 42b84d57390e4bcc14b5b78c3d5666b6e16b0427dd65c7ac958873219dd8336d + md5: 2e3452376c0c57290c2cef2509b7b04a + depends: + - python >=3.9 + - python + - python * + - pytorch >=2.3.0 + - simpleitk >=2.2.1 + - nibabel + - numpy + - scipy + - scikit-image + - matplotlib-base + - tqdm + - pandas + - hydra-core + run_exports: {} + size: 122240 + timestamp: 1792805761413 - conda: https://prefix.dev/akhanf/khanlab/noarch/imaris-ims-zarr-0.2.0-pyh4616a5c_0.conda sha256: 102710be3f11e2b3e639ee62712b83b595ef44466ed620b4231e15a607a14b1e md5: c4f1fb6373694cc0725098776d1b8931 diff --git a/pyproject.toml b/pyproject.toml index 49912275..78d69550 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -134,6 +134,8 @@ platforms = ["linux-64-cuda"] [tool.pixi.feature.gpu.dependencies] cuda-version = "12.6.*" pytorch-gpu = "*" +# cohort template building (khanlab fork, from akhanf/khanlab) +fireants = ">=1.4.0.post1" # ---------------------------------------------------------------------- diff --git a/spimquant/workflow/envs/fireants.yaml b/spimquant/workflow/envs/fireants.yaml deleted file mode 100644 index a08d428b..00000000 --- a/spimquant/workflow/envs/fireants.yaml +++ /dev/null @@ -1,14 +0,0 @@ -name: fireants -channels: - - conda-forge -dependencies: - - python=3.11 - - pip - - pip: - # PyPI torch wheels bundle the CUDA runtime, so the env can be created on a - # node without a GPU. FireANTs pins simpleitk==2.2.1, which is why it lives in - # its own env instead of the main pixi environment. - - torch==2.5.1 - # khanlab fork; multi-stain templates need the per-channel template saving - # in build_template.py (template_{epoch}_ch-{c}.nii.gz) - - fireants @ git+https://github.com/khanlab/FireANTs@a812cd1b16837ed3c0f62675eaa9d8e67f2a4ee9 diff --git a/spimquant/workflow/rules/cohort_template.smk b/spimquant/workflow/rules/cohort_template.smk index b597452b..ad304e21 100644 --- a/spimquant/workflow/rules/cohort_template.smk +++ b/spimquant/workflow/rules/cohort_template.smk @@ -248,8 +248,6 @@ rule build_cohort_template: build_dir=directory(os.path.join(cohort_work, "fireants", "buildtemplate")), log: os.path.join(cohort_work, "logs", "build_cohort_template.log"), - conda: - "../envs/fireants.yaml" threads: 8 * cohort_n_gpus resources: gpu=cohort_n_gpus, @@ -268,6 +266,10 @@ rule build_cohort_template: os.path.join(output.build_dir, "..", "hydra") ), shell: + # FireANTs is only in the gpu pixi environment + "python -c 'import fireants' 2>/dev/null || {{ echo 'FireANTs is not " + "installed: run SPIMquant from the gpu environment, e.g. pixi run -e gpu " + "spimquant ...' >&2; exit 1; }} && " "torchrun --standalone --nproc_per_node {resources.gpu} " " -m fireants.scripts.template.build_template " " --config-dir {params.config_dir} --config-name {params.config_name} " From 6ff3bafee88a8d59fdb5155a65335436e38c3520 Mon Sep 17 00:00:00 2001 From: "Ali R. Khan" Date: Sat, 24 Oct 2026 10:29:09 -0400 Subject: [PATCH 4/6] Cohort template: disable shape averaging, add resample option, fused ops - shape_avg now defaults to false: it adds a full-resolution registration per iteration, OOMs at 25um on a 40GB GPU, and gave a worse subject fit - --cohort-template-resample-mm resamples subject images to an isotropic voxel size before template building, to cut memory and run time (e.g. with a finer --cohort-template-level) - set PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True for torchrun, to avoid OOM from fragmentation after a few subjects - add fireants-fused-ops (akhanf/khanlab) to the gpu environment Co-Authored-By: Claude Opus 5.5 --- docs/howto/cohort_template.md | 11 +++++++++ pixi.lock | 24 +++++++++++++++++-- pyproject.toml | 1 + spimquant/config/snakebids.yml | 10 +++++++- spimquant/workflow/rules/cohort_template.smk | 8 +++++-- .../scripts/cohort_template_inputs.py | 21 ++++++++++++++-- 6 files changed, 68 insertions(+), 7 deletions(-) diff --git a/docs/howto/cohort_template.md b/docs/howto/cohort_template.md index 302e1089..d71527ce 100644 --- a/docs/howto/cohort_template.md +++ b/docs/howto/cohort_template.md @@ -66,6 +66,17 @@ FireANTs is only installed in the `gpu` pixi environment, so run this level from it, e.g. `pixi run -e gpu spimquant ...`. It uses the fused CUDA ops if they are installed, falling back to the plain implementation otherwise. +Shape averaging (`cohort_template.fireants.shape_avg`) is off by default: it adds +a full-resolution registration per iteration, runs out of memory at the 25um +ABAv3 resolution on a 40GB GPU, and in testing gave a worse subject fit than the +plain average. To lower memory use and run time, resample the subject images +before template building with e.g. `--cohort-template-resample-mm 0.05`, useful +when using a finer `--cohort-template-level`. On an 18-subject cohort, a 50um +template built in ~7 min vs ~50 min at 25um. + +The fused CUDA ops (`fireants-fused-ops`, from the `akhanf/khanlab` channel) are +installed in the gpu environment and reduce FireANTs' GPU memory use. + ## Preparing the template for SPIMquant ``` diff --git a/pixi.lock b/pixi.lock index fe553f7e..6c8368b2 100644 --- a/pixi.lock +++ b/pixi.lock @@ -6,7 +6,7 @@ platforms: - __linux=4.18 - __glibc=2.28 - __archspec=0=x86_64 -- name: p1 +- name: linux-64-cuda subdir: linux-64 virtual-packages: - __cuda=12.0 @@ -1590,7 +1590,7 @@ environments: - url: https://conda.anaconda.org/bioconda/ - url: https://prefix.dev/akhanf/khanlab/ packages: - p1: + linux-64-cuda: - conda: https://conda.anaconda.org/bioconda/noarch/snakebids-0.15.0-pyhdfd78af_0.conda - conda: https://conda.anaconda.org/bioconda/noarch/snakemake-9.22.0-hdfd78af_0.conda - conda: https://conda.anaconda.org/bioconda/noarch/snakemake-executor-plugin-slurm-1.9.2-pyhdfd78af_0.conda @@ -2232,6 +2232,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/zarr-3.4.1-pyhc364b38_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zict-3.0.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.1-pyh5ded981_0.conda + - conda: https://prefix.dev/akhanf/khanlab/linux-64/fireants-fused-ops-1.2.0-py312h8f1aada_0.conda - conda: https://prefix.dev/akhanf/khanlab/noarch/dynamic-network-architectures-0.4.4-pyh4616a5c_0.conda - conda: https://prefix.dev/akhanf/khanlab/noarch/fireants-1.4.0.post1-pyh4616a5c_0.conda - conda: https://prefix.dev/akhanf/khanlab/noarch/imaris-ims-zarr-0.2.0-pyh4616a5c_0.conda @@ -14717,6 +14718,25 @@ packages: run_exports: {} size: 24839 timestamp: 1791091060207 +- conda: https://prefix.dev/akhanf/khanlab/linux-64/fireants-fused-ops-1.2.0-py312h8f1aada_0.conda + sha256: 39dcc598db334fce90372ec087fdb11555756615fbaa1a69dba6501f38d4bfbb + md5: 90a466412345bc5e7e62061b69ae2d0d + depends: + - python + - pytorch >=2.7.1,<2.8.0a0 + - pytorch * cuda* + - __cuda + - __glibc >=2.17,<3.0.a0 + - libstdcxx >=13 + - libgcc >=13 + - cuda-version >=12.6,<13 + - python_abi 3.12.* *_cp312 + - cuda-cudart >=12.6.77,<13.0a0 + - libtorch >=2.7.1,<2.8.0a0 + license: LicenseRef-FireANTs + run_exports: {} + size: 16241882 + timestamp: 1792851202904 - conda: https://prefix.dev/akhanf/khanlab/noarch/dynamic-network-architectures-0.4.4-pyh4616a5c_0.conda sha256: c7965561ba0394584ed69a9f42732f229d95efb74a5d0d650e7281095b9aec59 md5: e9df3172e5221a85e89938b877d849c6 diff --git a/pyproject.toml b/pyproject.toml index 78d69550..2b6d731d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -136,6 +136,7 @@ cuda-version = "12.6.*" pytorch-gpu = "*" # cohort template building (khanlab fork, from akhanf/khanlab) fireants = ">=1.4.0.post1" +fireants-fused-ops = ">=1.2.0" # ---------------------------------------------------------------------- diff --git a/spimquant/config/snakebids.yml b/spimquant/config/snakebids.yml index 0b38a00b..25b60f14 100644 --- a/spimquant/config/snakebids.yml +++ b/spimquant/config/snakebids.yml @@ -502,6 +502,11 @@ parse_args: default: 10 type: int + --cohort-template-resample-mm: + help: "Isotropic voxel size (mm) to resample the subject images to before building the cohort template, e.g. 0.05. Lowers GPU memory use and run time, e.g. for a finer --cohort-template-level or --template. (default: --template resolution)" + default: null + type: float + --cohort-template-n-gpus: help: "Number of GPUs for FireANTs template building; subjects are split across GPUs (default: %(default)s)" default: 1 @@ -655,7 +660,10 @@ cohort_template: laplace_params: learning_rate: 0.5 itk_scale: true - shape_avg: true + # shape averaging runs an extra full-resolution registration per iteration + # (and OOMs at 25um on a 40GB GPU); the init template is already the subject + # average in --template space, and without it the subjects fit better + shape_avg: false save_every: 1 save_init_template: true save_moved_images: true diff --git a/spimquant/workflow/rules/cohort_template.smk b/spimquant/workflow/rules/cohort_template.smk index ad304e21..ccca3f5d 100644 --- a/spimquant/workflow/rules/cohort_template.smk +++ b/spimquant/workflow/rules/cohort_template.smk @@ -208,8 +208,8 @@ rule all_cohort_template: rule cohort_template_inputs: """Normalize subject images and write the FireANTs image list and config. - Each image is clipped to robust percentiles of its nonzero voxels and - rescaled to [0,1]. The image list has one comma-separated row per subject + Images are optionally resampled to --cohort-template-resample-mm. Each image + is clipped to robust percentiles of its nonzero voxels and rescaled to [0,1]. The image list has one comma-separated row per subject (one entry per stain), which FireANTs builds as one multivariate template. """ input: @@ -226,6 +226,7 @@ rule cohort_template_inputs: rows=cohort_rows, stains=cohort_stains, clip_percentiles=config["cohort_template"]["clip_percentiles"], + resample_mm=config["cohort_template_resample_mm"], fireants=lambda wildcards, output: { **config["cohort_template"]["fireants"], "template_iterations": cohort_iterations, @@ -270,6 +271,9 @@ rule build_cohort_template: "python -c 'import fireants' 2>/dev/null || {{ echo 'FireANTs is not " "installed: run SPIMquant from the gpu environment, e.g. pixi run -e gpu " "spimquant ...' >&2; exit 1; }} && " + # full-res deformable steps can run close to the GPU memory limit, and + # fragmentation otherwise causes OOM after a few subjects + "PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True " "torchrun --standalone --nproc_per_node {resources.gpu} " " -m fireants.scripts.template.build_template " " --config-dir {params.config_dir} --config-name {params.config_name} " diff --git a/spimquant/workflow/scripts/cohort_template_inputs.py b/spimquant/workflow/scripts/cohort_template_inputs.py index 2a0463b3..d56ebc79 100644 --- a/spimquant/workflow/scripts/cohort_template_inputs.py +++ b/spimquant/workflow/scripts/cohort_template_inputs.py @@ -1,7 +1,8 @@ """Prepare subject images for FireANTs cohort template building. -Each subject image (already in the original template space) is clipped to -robust percentiles of its nonzero voxels and rescaled to [0, 1]. Writes: +Each subject image (already in the original template space) is optionally +resampled to an isotropic voxel size, then clipped to robust percentiles of its +nonzero voxels and rescaled to [0, 1]. Writes: - the normalized images, one per subject and stain - an image list with one comma-separated row per subject (one entry per stain, @@ -14,12 +15,26 @@ import nibabel as nib import numpy as np import yaml +from nibabel.affines import rescale_affine, voxel_sizes +from nibabel.processing import resample_from_to rows = snakemake.params.rows stains = snakemake.params.stains p_lo, p_hi = snakemake.params.clip_percentiles +resample_mm = snakemake.params.resample_mm out_dir = snakemake.output.images + +def resample(img): + """Linearly resample img to resample_mm isotropic, covering the same extent.""" + zooms = voxel_sizes(img.affine) + new_shape = np.maximum( + np.round(np.array(img.shape[:3]) * zooms / resample_mm), 1 + ).astype(int) + new_affine = rescale_affine(img.affine, img.shape[:3], resample_mm, new_shape) + return resample_from_to(img, (tuple(new_shape), new_affine), order=1) + + os.makedirs(out_dir, exist_ok=True) image_list_rows = [] @@ -27,6 +42,8 @@ out_paths = [] for stain, in_path in zip(stains, row["images"]): img = nib.load(in_path) + if resample_mm is not None: + img = resample(img) data = np.asarray(img.dataobj, dtype=np.float32) nonzero = data[data > 0] if nonzero.size == 0: From 6e29ed36eba4e9ee0f62e2e36c1ff2aeafd5e960 Mon Sep 17 00:00:00 2001 From: "Ali R. Khan" Date: Sat, 24 Oct 2026 12:41:07 -0400 Subject: [PATCH 5/6] Record the cohort template input resampling in template_description.json Co-Authored-By: Claude Opus 5.5 --- spimquant/workflow/rules/cohort_template.smk | 1 + spimquant/workflow/scripts/cohort_template_description.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/spimquant/workflow/rules/cohort_template.smk b/spimquant/workflow/rules/cohort_template.smk index ccca3f5d..4929e967 100644 --- a/spimquant/workflow/rules/cohort_template.smk +++ b/spimquant/workflow/rules/cohort_template.smk @@ -631,6 +631,7 @@ rule cohort_template_description: stains=cohort_stains, level=cohort_level, iterations=cohort_iterations, + resample_mm=config["cohort_template_resample_mm"], subjects=[row["name"] for row in cohort_rows], fireants=config["cohort_template"]["fireants"], reg=config["cohort_template"]["reg"], diff --git a/spimquant/workflow/scripts/cohort_template_description.py b/spimquant/workflow/scripts/cohort_template_description.py index cae54372..e3adcc37 100644 --- a/spimquant/workflow/scripts/cohort_template_description.py +++ b/spimquant/workflow/scripts/cohort_template_description.py @@ -90,6 +90,8 @@ def ref(fname): "OriginalTemplate": p.orig_template, "Stains": list(p.stains), "InputLevel": p.level, + # isotropic voxel size (mm) the inputs were resampled to, null if not resampled + "InputResampleMM": p.resample_mm, "TemplateIterations": p.iterations, "Subjects": list(p.subjects), "Atlases": list(entry["atlases"].keys()), From 742ff5eaed6d1706d9972daecdd1e07d747bbf3b Mon Sep 17 00:00:00 2001 From: "Ali R. Khan" Date: Sat, 24 Oct 2026 14:15:27 -0400 Subject: [PATCH 6/6] Export the cohort template as a ready-to-use --configfile The exported tpl-{name}_spimquant.yml now selects the template (template:) and points at the exported files' absolute paths (or the Zenodo URLs), so it can be passed to any run with --configfile, without editing snakebids.yml. Built-in --template choices are added by PR instead. Co-Authored-By: Claude Opus 5.5 --- docs/howto/cohort_template.md | 27 ++++++++++++++----- .../scripts/cohort_template_description.py | 26 +++++++++--------- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/docs/howto/cohort_template.md b/docs/howto/cohort_template.md index d71527ce..cc3ded0e 100644 --- a/docs/howto/cohort_template.md +++ b/docs/howto/cohort_template.md @@ -87,9 +87,8 @@ spimquant /path/to/bids /path/to/output cohort_template_export \ Use the same `cohort_template` options as when building. This writes `tpl-lecanemab/template_description.json`, `tpl-lecanemab_buildinfo.tar.gz` (image -list, FireANTs config and logs), and `tpl-lecanemab_spimquant.yml`, a `templates:` -entry to add to `spimquant/config/snakebids.yml` (also add the name to the -`--template` choices). +list, FireANTs config and logs), and `tpl-lecanemab_spimquant.yml`, a SPIMquant +config file that adds the template and selects it. To upload to Zenodo, create a draft upload on Zenodo and pass its deposition ID, with a Zenodo access token in the environment: @@ -102,7 +101,23 @@ spimquant /path/to/bids /path/to/output cohort_template_export \ --cohort-template-zenodo-deposition 12345678 ``` -The config entry then points at `https://zenodo.org/records//files/...` URLs, +The config file then points at `https://zenodo.org/records//files/...` URLs, which resolve once you publish the deposition. Without a deposition, it points at -`tpl-lecanemab/...` paths relative to `spimquant/resources/`, where you can copy the -`tpl-lecanemab` directory instead. +the absolute paths of the exported files. + +## Using the template + +Pass the config file to any SPIMquant run, in place of `--template`: + +``` +spimquant /path/to/bids /path/to/output participant \ + --configfile tpl-lecanemab_spimquant.yml +``` + +The registration stain must be one of the cohort template's stains (see +`--stains_for_reg`). + +To make the template a built-in `--template` choice, open a PR that moves its +`templates:` entry into `spimquant/config/snakebids.yml` (with the Zenodo URLs, or +paths relative to `spimquant/resources/` after copying `tpl-lecanemab` there), and +adds the name to the `--template` choices. diff --git a/spimquant/workflow/scripts/cohort_template_description.py b/spimquant/workflow/scripts/cohort_template_description.py index e3adcc37..c174fddf 100644 --- a/spimquant/workflow/scripts/cohort_template_description.py +++ b/spimquant/workflow/scripts/cohort_template_description.py @@ -1,8 +1,8 @@ """Write template_description.json and a SPIMquant templates: config snippet. -The snippet points at Zenodo record URLs when a deposition ID was given, and -otherwise at paths relative to spimquant/resources/ (after copying the -tpl-{name} directory there). +The snippet is a SPIMquant config file that selects the cohort template, for +use with --configfile. It points at Zenodo record URLs when a deposition ID was +given, and otherwise at the exported files' absolute paths. """ import json @@ -24,7 +24,7 @@ def ref(fname): raise ValueError(f"{fname} is not one of the exported files") if p.zenodo_deposition is not None: return f"https://zenodo.org/records/{p.zenodo_deposition}/files/{fname}" - return f"tpl-{name}/{fname}" + return os.path.abspath(files[fname]) anat_fname = f"tpl-{name}_anat.nii.gz" @@ -52,20 +52,22 @@ def ref(fname): ) else: location = ( - f"# Paths are relative to spimquant/resources/: copy {p.template_dir}\n" - "# there, or rerun cohort_template_export with\n" - "# --cohort-template-zenodo-deposition to upload to Zenodo.\n" + f"# Paths point at the exported files in {os.path.abspath(p.template_dir)};\n" + "# rerun cohort_template_export with --cohort-template-zenodo-deposition to\n" + "# upload to Zenodo instead.\n" ) with open(snakemake.output.config_snippet, "w") as f: f.write( - f"# SPIMquant templates: entry for tpl-{name}, a cohort template built from\n" + f"# SPIMquant config for tpl-{name}, a cohort template built from\n" f"# {len(p.subjects)} subjects registered to tpl-{p.orig_template}, with atlases\n" - f"# warped from tpl-{p.orig_template}.\n" - f"# Add it under templates: in spimquant/config/snakebids.yml, and add {name}\n" - "# to the --template choices.\n" + location + f"# warped from tpl-{p.orig_template}. Use it with:\n" + f"# spimquant ... --configfile tpl-{name}_spimquant.yml\n" + "# To add it to SPIMquant itself, move the templates: entry into\n" + f"# spimquant/config/snakebids.yml and add {name} to the --template choices.\n" + + location ) - yaml.safe_dump({"templates": {name: entry}}, f, sort_keys=False) + yaml.safe_dump({"template": name, "templates": {name: entry}}, f, sort_keys=False) anat = nib.load(files[anat_fname])