From 1ba6f5acf7c5ead4bb9eb1f8614ca75af2e444ac Mon Sep 17 00:00:00 2001 From: Jason Kai <21226986+kaitj@users.noreply.github.com> Date: Fri, 8 May 2026 10:52:03 -0400 Subject: [PATCH 01/12] Resample long templ to bold grid for ref --- src/rbc/core/longitudinal/resampling.py | 46 ++++++++++++++++++++ src/rbc/workflows/longitudinal/functional.py | 5 ++- 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 src/rbc/core/longitudinal/resampling.py diff --git a/src/rbc/core/longitudinal/resampling.py b/src/rbc/core/longitudinal/resampling.py new file mode 100644 index 00000000..da53c285 --- /dev/null +++ b/src/rbc/core/longitudinal/resampling.py @@ -0,0 +1,46 @@ +"""Resampling utiltiies for longitudinal templates.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import nibabel as nib +from nibabel.processing import resample_from_to + +if TYPE_CHECKING: + from pathlib import Path + +from rbc.core.niwrap import generate_exec_folder + + +def resample_template_to_bold(bold_ref: Path, template: Path) -> Path: + """Resample template to BOLD grid if shapes differ. + + Args: + bold_ref: BOLD reference volume (used for ITK conversion). + template: Brain template in target space. + + Returns: + Resampled template image with BOLD grid + + Raises: + FileNotFoundError: No motion .mat files found in the directory. + ValueError: Number of motion matrices does not match STC volumes. + """ + bold_ref_img = nib.nifti1.load(bold_ref) + template_img = nib.nifti1.load(template) + + # If 4D, extract first volume + if len(bold_ref_img.shape) > 3: + bold_ref_img = bold_ref_img[..., 0] + # If same shape, no need to resample + if bold_ref_img.shape == template_img.shape: + return template_img + + template_img = resample_from_to(template_img, bold_ref_img) + template_img_path = ( + generate_exec_folder("template_resample_to_bold_grid") + / "template_resampled.nii.gz" + ) + nib.save(template_img, template_img_path) + return template_img_path diff --git a/src/rbc/workflows/longitudinal/functional.py b/src/rbc/workflows/longitudinal/functional.py index fdf7b669..5926fba7 100644 --- a/src/rbc/workflows/longitudinal/functional.py +++ b/src/rbc/workflows/longitudinal/functional.py @@ -12,6 +12,7 @@ from typing import TYPE_CHECKING, NamedTuple from rbc.core.functional import apply_regression, apply_regression_bandpass +from rbc.core.longitudinal.resampling import resample_template_to_bold from rbc.core.longitudinal.transform import ( compose_transform, func_transform, @@ -82,8 +83,10 @@ def longitudinal_process( :class:`FunctionalLongOutputs` with all inputs transformed to longitudinal template space and per-regressor regression outputs. """ + template_resampled = resample_template_to_bold(bold_ref=sbref, template=template) + bold_to_tpl_xfm = compose_transform( - ref=template, + ref=template_resampled, bold_to_anat_itk=bold_to_anat_itk, anat_to_tpl_xfm=anat_to_template_xfm, ) From 05f0b3e6cec0dd1a9ed1f7f6d7e79cdb3beae858 Mon Sep 17 00:00:00 2001 From: Jason Kai <21226986+kaitj@users.noreply.github.com> Date: Fri, 8 May 2026 13:13:20 -0400 Subject: [PATCH 02/12] Generate bold ref once after template created --- src/rbc/bids/longitudinal/functional.py | 2 +- src/rbc/bids/longitudinal/template.py | 19 ++++++++++++++++++- .../orchestration/longitudinal/template.py | 1 + src/rbc/workflows/longitudinal/functional.py | 5 +---- src/rbc/workflows/longitudinal/template.py | 9 +++++++++ 5 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/rbc/bids/longitudinal/functional.py b/src/rbc/bids/longitudinal/functional.py index 0ac9d457..3f156811 100644 --- a/src/rbc/bids/longitudinal/functional.py +++ b/src/rbc/bids/longitudinal/functional.py @@ -51,7 +51,7 @@ def resolve_longitudinal_func( ) return { - "template": tpl_q.expect(tpl_df, suffix="T1w"), + "template": tpl_q.expect(tpl_df, res="bold", suffix="T1w"), "anat_to_template_xfm": tpl_q.expect( tpl_df, suffix="xfm", diff --git a/src/rbc/bids/longitudinal/template.py b/src/rbc/bids/longitudinal/template.py index 8b5784f9..12598201 100644 --- a/src/rbc/bids/longitudinal/template.py +++ b/src/rbc/bids/longitudinal/template.py @@ -21,11 +21,13 @@ class TemplateInputs(NamedTuple): sub: Subject label. sessions: Per-input session labels (parallel to ``files``). files: Per-session preprocessed T1w brain volumes. + bold_ref: First BOLD volume for grid reference """ sub: str sessions: list[str] files: list[Path] + bold_ref: Path | None def discover_template_inputs( @@ -56,6 +58,17 @@ def discover_template_inputs( # the mri_robust_template invocation. pl.col("space").is_null(), ) + bold_ref_rows = df.filter( + pl.col("ses") != "longitudinal", + pl.col("datatype") == "func", + pl.col("suffix") == "bold", + pl.col("space").is_null(), + ) + bold_ref = ( + None + if bold_ref_rows.is_empty() + else Path(bold_ref_rows["root"][0]) / bold_ref_rows["path"][0] + ) inputs: list[TemplateInputs] = [] skipped: list[str] = [] @@ -68,7 +81,9 @@ def discover_template_inputs( files = [ Path(row["root"]) / row["path"] for row in sub_group.iter_rows(named=True) ] - inputs.append(TemplateInputs(sub=sub, sessions=sessions, files=files)) + inputs.append( + TemplateInputs(sub=sub, sessions=sessions, files=files, bold_ref=bold_ref) + ) return inputs, skipped @@ -81,6 +96,8 @@ def export_template(tpl: Bids, outputs: LongitudinalTemplateOutputs) -> None: outputs: Results from the longitudinal template workflow. """ tpl.save(outputs.template, suffix=Suffix.T1W) + if outputs.bold_template is not None: + tpl.save(outputs.bold_template, res="bold", suffix=Suffix.T1W) for ses, xfm in zip(outputs.sessions, outputs.transforms, strict=True): ses_label = bids_safe_label(ses) tpl.save( diff --git a/src/rbc/orchestration/longitudinal/template.py b/src/rbc/orchestration/longitudinal/template.py index 026c3b65..6091e776 100644 --- a/src/rbc/orchestration/longitudinal/template.py +++ b/src/rbc/orchestration/longitudinal/template.py @@ -49,6 +49,7 @@ def process_subject( sub=inputs.sub, sessions=inputs.sessions, in_files=inputs.files, + bold_ref=inputs.bold_ref, ) tpl = pipe_ctx.bids(datatype=Datatype.ANAT).derive(ses="longitudinal") export_template(tpl, outputs) diff --git a/src/rbc/workflows/longitudinal/functional.py b/src/rbc/workflows/longitudinal/functional.py index 5926fba7..fdf7b669 100644 --- a/src/rbc/workflows/longitudinal/functional.py +++ b/src/rbc/workflows/longitudinal/functional.py @@ -12,7 +12,6 @@ from typing import TYPE_CHECKING, NamedTuple from rbc.core.functional import apply_regression, apply_regression_bandpass -from rbc.core.longitudinal.resampling import resample_template_to_bold from rbc.core.longitudinal.transform import ( compose_transform, func_transform, @@ -83,10 +82,8 @@ def longitudinal_process( :class:`FunctionalLongOutputs` with all inputs transformed to longitudinal template space and per-regressor regression outputs. """ - template_resampled = resample_template_to_bold(bold_ref=sbref, template=template) - bold_to_tpl_xfm = compose_transform( - ref=template_resampled, + ref=template, bold_to_anat_itk=bold_to_anat_itk, anat_to_tpl_xfm=anat_to_template_xfm, ) diff --git a/src/rbc/workflows/longitudinal/template.py b/src/rbc/workflows/longitudinal/template.py index c76494b7..246d36a1 100644 --- a/src/rbc/workflows/longitudinal/template.py +++ b/src/rbc/workflows/longitudinal/template.py @@ -14,6 +14,7 @@ fs_to_itk_xfm, generate_robust_template, ) +from rbc.core.longitudinal.resampling import resample_template_to_bold if TYPE_CHECKING: from collections.abc import Sequence @@ -27,11 +28,13 @@ class LongitudinalTemplateOutputs(NamedTuple): Attributes: template: Robust within-subject template volume. + bold_template: Within-subject template volume resampled to BOLD resolution. sessions: Session labels in the same order as ``transforms``. transforms: Per-session ITK-format session-to-template transforms. """ template: Path + bold_template: Path | None sessions: list[str] transforms: list[Path] @@ -40,6 +43,7 @@ def generate_subject_template( sub: str, sessions: Sequence[str], in_files: Sequence[Path], + bold_ref: Path | None = None, ) -> LongitudinalTemplateOutputs: """Build a robust template and ITK transforms for one subject. @@ -47,6 +51,7 @@ def generate_subject_template( sub: Subject label (without the ``sub-`` prefix). sessions: Session labels parallel to ``in_files``. in_files: Per-session preprocessed T1w volumes (e.g. brain-extracted). + bold_ref: Reference bold volume to resample template for functional data. Returns: :class:`LongitudinalTemplateOutputs` ready for BIDS export. @@ -65,8 +70,12 @@ def generate_subject_template( in_xfms=robust.transforms, ) + if bold_ref is not None: + bold_ref = resample_template_to_bold(bold_ref, robust.template) + return LongitudinalTemplateOutputs( template=robust.template, + bold_template=bold_ref, sessions=list(sessions), transforms=itk_xfms, ) From 2f95effd83ecdb58f5aa7286c23181dbf5600c67 Mon Sep 17 00:00:00 2001 From: Jason Kai <21226986+kaitj@users.noreply.github.com> Date: Fri, 8 May 2026 13:13:44 -0400 Subject: [PATCH 03/12] Update tests for bold ref template --- .../unit/bids/test_longitudinal_functional.py | 10 ++-- tests/unit/bids/test_longitudinal_template.py | 48 +++++++++++++------ 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/tests/unit/bids/test_longitudinal_functional.py b/tests/unit/bids/test_longitudinal_functional.py index 98a17d41..861ba3e5 100644 --- a/tests/unit/bids/test_longitudinal_functional.py +++ b/tests/unit/bids/test_longitudinal_functional.py @@ -59,6 +59,7 @@ def _anat_row( sub: str, ses: str, suffix: str, + res: str | None = None, desc: str | None = None, ext: str = ".nii.gz", extra: list[dict[str, str]] | None = None, @@ -73,6 +74,7 @@ def _anat_row( "sub": sub, "ses": ses, "space": None, + "res": res, "desc": desc, "root": "/data", "path": path, @@ -143,7 +145,7 @@ def test_resolves_single_regressor(self, tmp_path: Path) -> None: ), ) tpl_df = _df( - _anat_row(sub="01", ses="longitudinal", suffix="T1w"), + _anat_row(sub="01", ses="longitudinal", res="bold", suffix="T1w"), _anat_row( sub="01", ses="longitudinal", @@ -218,7 +220,7 @@ def test_resolves_multiple_regressors(self, tmp_path: Path) -> None: ), ) tpl_df = _df( - _anat_row(sub="01", ses="longitudinal", suffix="T1w"), + _anat_row(sub="01", ses="longitudinal", res="bold", suffix="T1w"), _anat_row( sub="01", ses="longitudinal", @@ -275,7 +277,7 @@ def test_missing_regressor_raises(self, tmp_path: Path) -> None: ), ) tpl_df = _df( - _anat_row(sub="01", ses="longitudinal", suffix="T1w"), + _anat_row(sub="01", ses="longitudinal", res="bold", suffix="T1w"), _anat_row( sub="01", ses="longitudinal", @@ -328,7 +330,7 @@ def test_bold_mask_mandatory(self, tmp_path: Path) -> None: ), ) tpl_df = _df( - _anat_row(sub="01", ses="longitudinal", suffix="T1w"), + _anat_row(sub="01", ses="longitudinal", res="bold", suffix="T1w"), _anat_row( sub="01", ses="longitudinal", diff --git a/tests/unit/bids/test_longitudinal_template.py b/tests/unit/bids/test_longitudinal_template.py index a9c80e63..f4b8ec7f 100644 --- a/tests/unit/bids/test_longitudinal_template.py +++ b/tests/unit/bids/test_longitudinal_template.py @@ -35,7 +35,7 @@ def _df(*rows: tuple) -> pl.DataFrame: return pl.DataFrame(dict(zip(_SCHEMA, zip(*rows, strict=True), strict=True))) -def _brain_row(sub: str, ses: str, space: str | None = None) -> tuple: +def _anat_row(sub: str, ses: str, space: str | None = None) -> tuple: space_part = f"_space-{space}" if space else "" path = ( f"sub-{sub}/ses-{ses}/anat/" @@ -56,16 +56,25 @@ def _brain_row(sub: str, ses: str, space: str | None = None) -> tuple: ) +def _func_row(sub: str, ses: str, task: str = "rest") -> tuple: + path = f"sub-{sub}/ses-{ses}/func/sub-{sub}_ses-{ses}_task-{task}_bold.nii.gz" + return ("func", "bold", ".nii.gz", sub, ses, None, task, None, None, "/data", path) + + class TestDiscoverTemplateInputs: """Tests for :func:`discover_template_inputs`.""" def test_groups_by_subject(self) -> None: """Multi-session subjects yield one TemplateInputs each.""" df = _df( - _brain_row("01", "baseline"), - _brain_row("01", "vis2"), - _brain_row("02", "baseline"), - _brain_row("02", "vis2"), + _anat_row("01", "baseline"), + _anat_row("01", "vis2"), + _anat_row("02", "baseline"), + _anat_row("02", "vis2"), + _func_row("01", "baseline"), + _func_row("01", "vis2"), + _func_row("02", "baseline"), + _func_row("02", "vis2"), ) inputs, skipped = discover_template_inputs(df) assert {ti.sub for ti in inputs} == {"01", "02"} @@ -77,9 +86,12 @@ def test_groups_by_subject(self) -> None: def test_reports_single_session_subject(self) -> None: """Single-session subjects are reported separately (bug #19).""" df = _df( - _brain_row("01", "baseline"), - _brain_row("01", "vis2"), - _brain_row("02", "baseline"), + _anat_row("01", "baseline"), + _anat_row("01", "vis2"), + _anat_row("02", "baseline"), + _func_row("01", "baseline"), + _func_row("01", "vis2"), + _func_row("02", "baseline"), ) inputs, skipped = discover_template_inputs(df) assert [ti.sub for ti in inputs] == ["01"] @@ -88,9 +100,12 @@ def test_reports_single_session_subject(self) -> None: def test_excludes_existing_longitudinal(self) -> None: """Pre-existing longitudinal templates are not re-included as inputs.""" df = _df( - _brain_row("01", "baseline"), - _brain_row("01", "vis2"), - _brain_row("01", "longitudinal"), + _anat_row("01", "baseline"), + _anat_row("01", "vis2"), + _anat_row("01", "longitudinal"), + _func_row("01", "baseline"), + _func_row("01", "vis2"), + _func_row("01", "longitudinal"), ) inputs, skipped = discover_template_inputs(df) assert len(inputs) == 1 @@ -111,10 +126,10 @@ def test_excludes_mni_registered_brains(self) -> None: duplicate LTA filenames in the mri_robust_template invocation. """ df = _df( - _brain_row("01", "test"), - _brain_row("01", "test", space="MNI152NLin6Asym"), - _brain_row("01", "retest"), - _brain_row("01", "retest", space="MNI152NLin6Asym"), + _anat_row("01", "test"), + _anat_row("01", "test", space="MNI152NLin6Asym"), + _anat_row("01", "retest"), + _anat_row("01", "retest", space="MNI152NLin6Asym"), ) inputs, skipped = discover_template_inputs(df) assert len(inputs) == 1 @@ -131,6 +146,8 @@ def test_writes_template_and_xfms(self, tmp_path: Path) -> None: """Template + per-session xfms land at the expected BIDS paths.""" template_src = tmp_path / "src_template.nii.gz" template_src.write_bytes(b"\x00") + template_bold_src = tmp_path / "src_bold_template.nii.gz" + template_bold_src.write_bytes(b"\x00") xfm_baseline = tmp_path / "xfm_baseline.txt" xfm_baseline.write_text("baseline") xfm_vis2 = tmp_path / "xfm_vis2.txt" @@ -138,6 +155,7 @@ def test_writes_template_and_xfms(self, tmp_path: Path) -> None: outputs = LongitudinalTemplateOutputs( template=template_src, + bold_template=template_bold_src, sessions=["baseline", "vis2"], transforms=[xfm_baseline, xfm_vis2], ) From 06929ef202bc8658783e54789d8f71b58556eb5d Mon Sep 17 00:00:00 2001 From: Jason Kai <21226986+kaitj@users.noreply.github.com> Date: Fri, 8 May 2026 13:33:48 -0400 Subject: [PATCH 04/12] Replace data loading for long template resampling --- src/rbc/bids/longitudinal/anatomical.py | 2 +- src/rbc/core/longitudinal/resampling.py | 6 +++--- src/rbc/workflows/longitudinal/template.py | 4 ++-- tests/unit/bids/test_longitudinal_anatomical.py | 1 + 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/rbc/bids/longitudinal/anatomical.py b/src/rbc/bids/longitudinal/anatomical.py index 8d8d4472..816b74eb 100644 --- a/src/rbc/bids/longitudinal/anatomical.py +++ b/src/rbc/bids/longitudinal/anatomical.py @@ -43,7 +43,7 @@ def resolve_longitudinal_anat( Dict with keys matching ``longitudinal_process`` parameters. """ return { - "template": tpl_q.expect(tpl_df, suffix=Suffix.T1W), + "template": tpl_q.expect(tpl_df, suffix=Suffix.T1W, without=["res"]), "subj_to_template_xfm": tpl_q.expect( tpl_df, suffix="xfm", diff --git a/src/rbc/core/longitudinal/resampling.py b/src/rbc/core/longitudinal/resampling.py index da53c285..d84ed789 100644 --- a/src/rbc/core/longitudinal/resampling.py +++ b/src/rbc/core/longitudinal/resampling.py @@ -1,4 +1,4 @@ -"""Resampling utiltiies for longitudinal templates.""" +"""Resampling utilities for longitudinal templates.""" from __future__ import annotations @@ -13,7 +13,7 @@ from rbc.core.niwrap import generate_exec_folder -def resample_template_to_bold(bold_ref: Path, template: Path) -> Path: +def resample_template_to_bold_grid(bold_ref: Path, template: Path) -> Path: """Resample template to BOLD grid if shapes differ. Args: @@ -32,7 +32,7 @@ def resample_template_to_bold(bold_ref: Path, template: Path) -> Path: # If 4D, extract first volume if len(bold_ref_img.shape) > 3: - bold_ref_img = bold_ref_img[..., 0] + bold_ref_img = nib.four_to_three(bold_ref_img)[0] # If same shape, no need to resample if bold_ref_img.shape == template_img.shape: return template_img diff --git a/src/rbc/workflows/longitudinal/template.py b/src/rbc/workflows/longitudinal/template.py index 246d36a1..ebc9a7df 100644 --- a/src/rbc/workflows/longitudinal/template.py +++ b/src/rbc/workflows/longitudinal/template.py @@ -14,7 +14,7 @@ fs_to_itk_xfm, generate_robust_template, ) -from rbc.core.longitudinal.resampling import resample_template_to_bold +from rbc.core.longitudinal.resampling import resample_template_to_bold_grid if TYPE_CHECKING: from collections.abc import Sequence @@ -71,7 +71,7 @@ def generate_subject_template( ) if bold_ref is not None: - bold_ref = resample_template_to_bold(bold_ref, robust.template) + bold_ref = resample_template_to_bold_grid(bold_ref, robust.template) return LongitudinalTemplateOutputs( template=robust.template, diff --git a/tests/unit/bids/test_longitudinal_anatomical.py b/tests/unit/bids/test_longitudinal_anatomical.py index d80f6c89..59cc5064 100644 --- a/tests/unit/bids/test_longitudinal_anatomical.py +++ b/tests/unit/bids/test_longitudinal_anatomical.py @@ -99,6 +99,7 @@ def _anat_row( "sub": sub, "ses": ses, "desc": desc, + "res": None, "root": "/data", "path": path, "extra_entities": extra or [], From 11b77227043266aaabd574db143b603fe7a0bc8d Mon Sep 17 00:00:00 2001 From: Jason Kai <21226986+kaitj@users.noreply.github.com> Date: Fri, 8 May 2026 15:50:54 -0400 Subject: [PATCH 05/12] Resample longitudinal mask for QC --- src/rbc/core/longitudinal/resampling.py | 23 +++++++++++----------- src/rbc/orchestration/longitudinal/qc.py | 5 +++++ src/rbc/workflows/longitudinal/template.py | 4 ++-- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/rbc/core/longitudinal/resampling.py b/src/rbc/core/longitudinal/resampling.py index d84ed789..422fd57a 100644 --- a/src/rbc/core/longitudinal/resampling.py +++ b/src/rbc/core/longitudinal/resampling.py @@ -13,34 +13,33 @@ from rbc.core.niwrap import generate_exec_folder -def resample_template_to_bold_grid(bold_ref: Path, template: Path) -> Path: +def resample_img_to_bold_grid(bold_ref: Path, img: Path) -> Path: """Resample template to BOLD grid if shapes differ. Args: bold_ref: BOLD reference volume (used for ITK conversion). - template: Brain template in target space. + img: 3D image in target space to resample. Returns: - Resampled template image with BOLD grid + Resampled 3D image with BOLD grid Raises: FileNotFoundError: No motion .mat files found in the directory. ValueError: Number of motion matrices does not match STC volumes. """ bold_ref_img = nib.nifti1.load(bold_ref) - template_img = nib.nifti1.load(template) + img_obj = nib.nifti1.load(img) # If 4D, extract first volume if len(bold_ref_img.shape) > 3: bold_ref_img = nib.four_to_three(bold_ref_img)[0] # If same shape, no need to resample - if bold_ref_img.shape == template_img.shape: - return template_img + if bold_ref_img.shape == img_obj.shape: + return img - template_img = resample_from_to(template_img, bold_ref_img) - template_img_path = ( - generate_exec_folder("template_resample_to_bold_grid") - / "template_resampled.nii.gz" + img_resampled = resample_from_to(img_obj, bold_ref_img) + img_resampled_path = ( + generate_exec_folder("img_resample_to_bold_grid") / "resampled.nii.gz" ) - nib.save(template_img, template_img_path) - return template_img_path + nib.save(img_resampled, img_resampled_path) + return img_resampled_path diff --git a/src/rbc/orchestration/longitudinal/qc.py b/src/rbc/orchestration/longitudinal/qc.py index 0fd8b7e0..df597657 100644 --- a/src/rbc/orchestration/longitudinal/qc.py +++ b/src/rbc/orchestration/longitudinal/qc.py @@ -21,6 +21,7 @@ write_longitudinal_qc_tsv, ) from rbc.bids.session import iter_session_files +from rbc.core.longitudinal.resampling import resample_img_to_bold_grid from rbc.core.niwrap import generate_exec_folder from rbc.core.qc.registration import registration_qc_metrics from rbc.orchestration import Filters, RunnerConfig, init_runner @@ -67,6 +68,10 @@ def process_qc( Returns: QC outputs with overlap metrics and pass/fail flag. """ + # Resample longitudinal anatomical mask to bold grid for QC purposes. + # Longitudinal processed data are registered to the longitudinal template with + # respective modality's native resolution + anat_brain_mask = resample_img_to_bold_grid(bold_mask, anat_brain_mask) anat_mask_arr = nib.nifti1.load(anat_brain_mask).get_fdata() bold_mask_arr = nib.nifti1.load(bold_mask).get_fdata() reg_metrics = registration_qc_metrics(anat_mask_arr, bold_mask_arr) diff --git a/src/rbc/workflows/longitudinal/template.py b/src/rbc/workflows/longitudinal/template.py index ebc9a7df..efbf2fe7 100644 --- a/src/rbc/workflows/longitudinal/template.py +++ b/src/rbc/workflows/longitudinal/template.py @@ -14,7 +14,7 @@ fs_to_itk_xfm, generate_robust_template, ) -from rbc.core.longitudinal.resampling import resample_template_to_bold_grid +from rbc.core.longitudinal.resampling import resample_img_to_bold_grid if TYPE_CHECKING: from collections.abc import Sequence @@ -71,7 +71,7 @@ def generate_subject_template( ) if bold_ref is not None: - bold_ref = resample_template_to_bold_grid(bold_ref, robust.template) + bold_ref = resample_img_to_bold_grid(bold_ref, robust.template) return LongitudinalTemplateOutputs( template=robust.template, From 6553dc5b78375cc5203ac9b03d869d4eb0988389 Mon Sep 17 00:00:00 2001 From: Jason Kai <21226986+kaitj@users.noreply.github.com> Date: Fri, 8 May 2026 17:09:43 -0400 Subject: [PATCH 06/12] Fix docstrings --- src/rbc/bids/longitudinal/template.py | 2 +- src/rbc/core/longitudinal/resampling.py | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/rbc/bids/longitudinal/template.py b/src/rbc/bids/longitudinal/template.py index 12598201..12894884 100644 --- a/src/rbc/bids/longitudinal/template.py +++ b/src/rbc/bids/longitudinal/template.py @@ -21,7 +21,7 @@ class TemplateInputs(NamedTuple): sub: Subject label. sessions: Per-input session labels (parallel to ``files``). files: Per-session preprocessed T1w brain volumes. - bold_ref: First BOLD volume for grid reference + bold_ref: First BOLD volume for grid reference. """ sub: str diff --git a/src/rbc/core/longitudinal/resampling.py b/src/rbc/core/longitudinal/resampling.py index 422fd57a..32921011 100644 --- a/src/rbc/core/longitudinal/resampling.py +++ b/src/rbc/core/longitudinal/resampling.py @@ -17,22 +17,18 @@ def resample_img_to_bold_grid(bold_ref: Path, img: Path) -> Path: """Resample template to BOLD grid if shapes differ. Args: - bold_ref: BOLD reference volume (used for ITK conversion). + bold_ref: BOLD reference volume. img: 3D image in target space to resample. Returns: Resampled 3D image with BOLD grid - - Raises: - FileNotFoundError: No motion .mat files found in the directory. - ValueError: Number of motion matrices does not match STC volumes. """ bold_ref_img = nib.nifti1.load(bold_ref) img_obj = nib.nifti1.load(img) # If 4D, extract first volume if len(bold_ref_img.shape) > 3: - bold_ref_img = nib.four_to_three(bold_ref_img)[0] + bold_ref_img = nib.four_to_three(bold_ref_img.slicer[..., 0])[0] # If same shape, no need to resample if bold_ref_img.shape == img_obj.shape: return img From ee0da2a3213aef7818f028c51b714dd671b123ae Mon Sep 17 00:00:00 2001 From: Jason Kai <21226986+kaitj@users.noreply.github.com> Date: Fri, 8 May 2026 17:18:12 -0400 Subject: [PATCH 07/12] Rename bold_ref to bold_template due to shadowing --- src/rbc/core/longitudinal/resampling.py | 2 +- src/rbc/workflows/longitudinal/template.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/rbc/core/longitudinal/resampling.py b/src/rbc/core/longitudinal/resampling.py index 32921011..a2e87a1f 100644 --- a/src/rbc/core/longitudinal/resampling.py +++ b/src/rbc/core/longitudinal/resampling.py @@ -28,7 +28,7 @@ def resample_img_to_bold_grid(bold_ref: Path, img: Path) -> Path: # If 4D, extract first volume if len(bold_ref_img.shape) > 3: - bold_ref_img = nib.four_to_three(bold_ref_img.slicer[..., 0])[0] + bold_ref_img = bold_ref_img.slicer[..., 0] # If same shape, no need to resample if bold_ref_img.shape == img_obj.shape: return img diff --git a/src/rbc/workflows/longitudinal/template.py b/src/rbc/workflows/longitudinal/template.py index efbf2fe7..f7eee1c0 100644 --- a/src/rbc/workflows/longitudinal/template.py +++ b/src/rbc/workflows/longitudinal/template.py @@ -70,12 +70,13 @@ def generate_subject_template( in_xfms=robust.transforms, ) - if bold_ref is not None: - bold_ref = resample_img_to_bold_grid(bold_ref, robust.template) + bold_template = ( + resample_img_to_bold_grid(bold_ref, robust.template) if bold_ref else None + ) return LongitudinalTemplateOutputs( template=robust.template, - bold_template=bold_ref, + bold_template=bold_template, sessions=list(sessions), transforms=itk_xfms, ) From 66085656400e3b6e2d93c90ecd400d76bdd51f9e Mon Sep 17 00:00:00 2001 From: Jason Kai <21226986+kaitj@users.noreply.github.com> Date: Mon, 11 May 2026 15:22:18 -0400 Subject: [PATCH 08/12] Refactor to resample template per task Templates for different takss _could_ be different resolutions. This refactors the resampling such that a template is generated per task for the longitudinal workflow. --- src/rbc/bids/longitudinal/functional.py | 3 +- src/rbc/bids/longitudinal/template.py | 28 +++++---- .../orchestration/longitudinal/template.py | 2 +- src/rbc/workflows/longitudinal/template.py | 18 +++--- .../unit/bids/test_longitudinal_functional.py | 57 +++++++++++++------ tests/unit/bids/test_longitudinal_template.py | 2 +- 6 files changed, 72 insertions(+), 38 deletions(-) diff --git a/src/rbc/bids/longitudinal/functional.py b/src/rbc/bids/longitudinal/functional.py index 3f156811..294679c2 100644 --- a/src/rbc/bids/longitudinal/functional.py +++ b/src/rbc/bids/longitudinal/functional.py @@ -50,8 +50,9 @@ def resolve_longitudinal_func( without=["space"], ) + task = func_df["task"].unique()[0] return { - "template": tpl_q.expect(tpl_df, res="bold", suffix="T1w"), + "template": tpl_q.expect(tpl_df, suffix="T1w", res=task), "anat_to_template_xfm": tpl_q.expect( tpl_df, suffix="xfm", diff --git a/src/rbc/bids/longitudinal/template.py b/src/rbc/bids/longitudinal/template.py index 12894884..bde54a6a 100644 --- a/src/rbc/bids/longitudinal/template.py +++ b/src/rbc/bids/longitudinal/template.py @@ -21,13 +21,13 @@ class TemplateInputs(NamedTuple): sub: Subject label. sessions: Per-input session labels (parallel to ``files``). files: Per-session preprocessed T1w brain volumes. - bold_ref: First BOLD volume for grid reference. + bold_ref: Per-session preprocessed BOLD volumes (all tasks). """ sub: str sessions: list[str] files: list[Path] - bold_ref: Path | None + bold_files: dict[str, Path] def discover_template_inputs( @@ -58,17 +58,13 @@ def discover_template_inputs( # the mri_robust_template invocation. pl.col("space").is_null(), ) - bold_ref_rows = df.filter( + bold_rows = df.filter( pl.col("ses") != "longitudinal", pl.col("datatype") == "func", + pl.col("desc") == "preproc", pl.col("suffix") == "bold", pl.col("space").is_null(), ) - bold_ref = ( - None - if bold_ref_rows.is_empty() - else Path(bold_ref_rows["root"][0]) / bold_ref_rows["path"][0] - ) inputs: list[TemplateInputs] = [] skipped: list[str] = [] @@ -81,8 +77,18 @@ def discover_template_inputs( files = [ Path(row["root"]) / row["path"] for row in sub_group.iter_rows(named=True) ] + # Filter for first found session; only single reference per task is needed + sub_bold = bold_rows.filter( + (pl.col("sub") == sub) & (pl.col("ses") == sessions[0]) + ) + bold_files = { + row["task"]: Path(row["root"]) / row["path"] + for row in sub_bold.iter_rows(named=True) + } inputs.append( - TemplateInputs(sub=sub, sessions=sessions, files=files, bold_ref=bold_ref) + TemplateInputs( + sub=sub, sessions=sessions, files=files, bold_files=bold_files + ) ) return inputs, skipped @@ -96,8 +102,8 @@ def export_template(tpl: Bids, outputs: LongitudinalTemplateOutputs) -> None: outputs: Results from the longitudinal template workflow. """ tpl.save(outputs.template, suffix=Suffix.T1W) - if outputs.bold_template is not None: - tpl.save(outputs.bold_template, res="bold", suffix=Suffix.T1W) + for btask, bold_template in outputs.bold_templates.items(): + tpl.save(bold_template, res=btask, suffix=Suffix.T1W) for ses, xfm in zip(outputs.sessions, outputs.transforms, strict=True): ses_label = bids_safe_label(ses) tpl.save( diff --git a/src/rbc/orchestration/longitudinal/template.py b/src/rbc/orchestration/longitudinal/template.py index 6091e776..a76b0631 100644 --- a/src/rbc/orchestration/longitudinal/template.py +++ b/src/rbc/orchestration/longitudinal/template.py @@ -49,7 +49,7 @@ def process_subject( sub=inputs.sub, sessions=inputs.sessions, in_files=inputs.files, - bold_ref=inputs.bold_ref, + bold_files=inputs.bold_files, ) tpl = pipe_ctx.bids(datatype=Datatype.ANAT).derive(ses="longitudinal") export_template(tpl, outputs) diff --git a/src/rbc/workflows/longitudinal/template.py b/src/rbc/workflows/longitudinal/template.py index f7eee1c0..5951a405 100644 --- a/src/rbc/workflows/longitudinal/template.py +++ b/src/rbc/workflows/longitudinal/template.py @@ -17,7 +17,7 @@ from rbc.core.longitudinal.resampling import resample_img_to_bold_grid if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Mapping, Sequence from pathlib import Path _logger = logging.getLogger("rbc") @@ -34,7 +34,7 @@ class LongitudinalTemplateOutputs(NamedTuple): """ template: Path - bold_template: Path | None + bold_templates: dict[str, Path] sessions: list[str] transforms: list[Path] @@ -43,7 +43,7 @@ def generate_subject_template( sub: str, sessions: Sequence[str], in_files: Sequence[Path], - bold_ref: Path | None = None, + bold_files: Mapping[str, Path], ) -> LongitudinalTemplateOutputs: """Build a robust template and ITK transforms for one subject. @@ -51,7 +51,7 @@ def generate_subject_template( sub: Subject label (without the ``sub-`` prefix). sessions: Session labels parallel to ``in_files``. in_files: Per-session preprocessed T1w volumes (e.g. brain-extracted). - bold_ref: Reference bold volume to resample template for functional data. + bold_files: Reference bold volumes to resample template for functional data. Returns: :class:`LongitudinalTemplateOutputs` ready for BIDS export. @@ -70,13 +70,15 @@ def generate_subject_template( in_xfms=robust.transforms, ) - bold_template = ( - resample_img_to_bold_grid(bold_ref, robust.template) if bold_ref else None - ) + _logger.info("Creating reference volumes for each functional task") + bold_templates = { + btask: resample_img_to_bold_grid(bfile, robust.template) + for btask, bfile in bold_files.items() + } return LongitudinalTemplateOutputs( template=robust.template, - bold_template=bold_template, + bold_templates=bold_templates, sessions=list(sessions), transforms=itk_xfms, ) diff --git a/tests/unit/bids/test_longitudinal_functional.py b/tests/unit/bids/test_longitudinal_functional.py index 861ba3e5..960cf96b 100644 --- a/tests/unit/bids/test_longitudinal_functional.py +++ b/tests/unit/bids/test_longitudinal_functional.py @@ -28,6 +28,7 @@ def _func_row( sub: str, ses: str, suffix: str, + task: str, desc: str | None = None, ext: str = ".nii.gz", space: str | None = None, @@ -38,11 +39,12 @@ def _func_row( space_part = f"_space-{space}" if space else "" path = ( f"sub-{sub}/ses-{ses}/func/" - f"sub-{sub}_ses-{ses}{space_part}{desc_part}_{suffix}{ext}" + f"sub-{sub}_ses-{ses}{space_part}_task-{task}{desc_part}_{suffix}{ext}" ) return { "datatype": "func", "suffix": suffix, + "task": task, "ext": ext, "sub": sub, "ses": ses, @@ -121,12 +123,17 @@ class TestResolveLongitudinalFunc: def test_resolves_single_regressor(self, tmp_path: Path) -> None: """Single regressor resolves raw regressor file from derivatives.""" func_df = _df( - _func_row(sub="01", ses="baseline", suffix="sbref"), - _func_row(sub="01", ses="baseline", suffix="bold", desc="preproc"), - _func_row(sub="01", ses="baseline", suffix="mask", desc="brain"), + _func_row(sub="01", ses="baseline", task="rest", suffix="sbref"), + _func_row( + sub="01", ses="baseline", task="rest", suffix="bold", desc="preproc" + ), + _func_row( + sub="01", ses="baseline", task="rest", suffix="mask", desc="brain" + ), _func_row( sub="01", ses="baseline", + task="rest", suffix="xfm", desc="linearITK", ext=".txt", @@ -139,13 +146,14 @@ def test_resolves_single_regressor(self, tmp_path: Path) -> None: _func_row( sub="01", ses="baseline", + task="rest", suffix="regressors", desc="36parameter", ext=".1D", ), ) tpl_df = _df( - _anat_row(sub="01", ses="longitudinal", res="bold", suffix="T1w"), + _anat_row(sub="01", ses="longitudinal", res="rest", suffix="T1w"), _anat_row( sub="01", ses="longitudinal", @@ -189,12 +197,17 @@ def test_resolves_single_regressor(self, tmp_path: Path) -> None: def test_resolves_multiple_regressors(self, tmp_path: Path) -> None: """Multiple regressors each get their own raw regressor file resolved.""" func_df = _df( - _func_row(sub="01", ses="baseline", suffix="sbref"), - _func_row(sub="01", ses="baseline", suffix="bold", desc="preproc"), - _func_row(sub="01", ses="baseline", suffix="mask", desc="brain"), + _func_row(sub="01", ses="baseline", task="rest", suffix="sbref"), + _func_row( + sub="01", ses="baseline", task="rest", suffix="bold", desc="preproc" + ), + _func_row( + sub="01", ses="baseline", task="rest", suffix="mask", desc="brain" + ), _func_row( sub="01", ses="baseline", + task="rest", suffix="xfm", desc="linearITK", ext=".txt", @@ -207,6 +220,7 @@ def test_resolves_multiple_regressors(self, tmp_path: Path) -> None: _func_row( sub="01", ses="baseline", + task="rest", suffix="regressors", desc="36parameter", ext=".1D", @@ -214,13 +228,14 @@ def test_resolves_multiple_regressors(self, tmp_path: Path) -> None: _func_row( sub="01", ses="baseline", + task="rest", suffix="regressors", desc="aCompCor", ext=".1D", ), ) tpl_df = _df( - _anat_row(sub="01", ses="longitudinal", res="bold", suffix="T1w"), + _anat_row(sub="01", ses="longitudinal", res="rest", suffix="T1w"), _anat_row( sub="01", ses="longitudinal", @@ -253,12 +268,17 @@ def test_resolves_multiple_regressors(self, tmp_path: Path) -> None: def test_missing_regressor_raises(self, tmp_path: Path) -> None: """Requesting a regressor not present in derivatives raises.""" func_df = _df( - _func_row(sub="01", ses="baseline", suffix="sbref"), - _func_row(sub="01", ses="baseline", suffix="bold", desc="preproc"), - _func_row(sub="01", ses="baseline", suffix="mask", desc="brain"), + _func_row(sub="01", ses="baseline", task="rest", suffix="sbref"), + _func_row( + sub="01", ses="baseline", task="rest", suffix="bold", desc="preproc" + ), + _func_row( + sub="01", ses="baseline", task="rest", suffix="mask", desc="brain" + ), _func_row( sub="01", ses="baseline", + task="rest", suffix="xfm", desc="linearITK", ext=".txt", @@ -271,13 +291,14 @@ def test_missing_regressor_raises(self, tmp_path: Path) -> None: _func_row( sub="01", ses="baseline", + task="rest", suffix="regressors", desc="36parameter", ext=".1D", ), ) tpl_df = _df( - _anat_row(sub="01", ses="longitudinal", res="bold", suffix="T1w"), + _anat_row(sub="01", ses="longitudinal", res="rest", suffix="T1w"), _anat_row( sub="01", ses="longitudinal", @@ -307,11 +328,14 @@ def test_missing_regressor_raises(self, tmp_path: Path) -> None: def test_bold_mask_mandatory(self, tmp_path: Path) -> None: """bold_mask is now resolved with expect(), so missing raises.""" func_df = _df( - _func_row(sub="01", ses="baseline", suffix="sbref"), - _func_row(sub="01", ses="baseline", suffix="bold", desc="preproc"), + _func_row(sub="01", ses="baseline", task="rest", suffix="sbref"), + _func_row( + sub="01", ses="baseline", task="rest", suffix="bold", desc="preproc" + ), _func_row( sub="01", ses="baseline", + task="rest", suffix="xfm", desc="linearITK", ext=".txt", @@ -324,13 +348,14 @@ def test_bold_mask_mandatory(self, tmp_path: Path) -> None: _func_row( sub="01", ses="baseline", + task="rest", suffix="regressors", desc="36parameter", ext=".1D", ), ) tpl_df = _df( - _anat_row(sub="01", ses="longitudinal", res="bold", suffix="T1w"), + _anat_row(sub="01", ses="longitudinal", res="rest", suffix="T1w"), _anat_row( sub="01", ses="longitudinal", diff --git a/tests/unit/bids/test_longitudinal_template.py b/tests/unit/bids/test_longitudinal_template.py index f4b8ec7f..8ff7031a 100644 --- a/tests/unit/bids/test_longitudinal_template.py +++ b/tests/unit/bids/test_longitudinal_template.py @@ -155,7 +155,7 @@ def test_writes_template_and_xfms(self, tmp_path: Path) -> None: outputs = LongitudinalTemplateOutputs( template=template_src, - bold_template=template_bold_src, + bold_templates={"test": template_bold_src}, sessions=["baseline", "vis2"], transforms=[xfm_baseline, xfm_vis2], ) From 6dc432d085cd664431291c6009e846118854aafc Mon Sep 17 00:00:00 2001 From: Jason Kai <21226986+kaitj@users.noreply.github.com> Date: Tue, 12 May 2026 10:29:52 -0400 Subject: [PATCH 09/12] Address review comments - Update stale docstring - Use "ents['task']" instead of filtering for - Address de-duping via assertion of grid for unique file paths - Fix resampling order for mask --- src/rbc/bids/longitudinal/functional.py | 3 ++- src/rbc/bids/longitudinal/template.py | 16 +++++++++++++++- src/rbc/core/longitudinal/resampling.py | 5 +++-- src/rbc/orchestration/longitudinal/functional.py | 1 + src/rbc/orchestration/longitudinal/qc.py | 2 +- src/rbc/workflows/longitudinal/template.py | 3 ++- tests/unit/bids/test_longitudinal_functional.py | 4 ++++ 7 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/rbc/bids/longitudinal/functional.py b/src/rbc/bids/longitudinal/functional.py index 294679c2..c4cb0eb9 100644 --- a/src/rbc/bids/longitudinal/functional.py +++ b/src/rbc/bids/longitudinal/functional.py @@ -23,6 +23,7 @@ def resolve_longitudinal_func( tpl_df: pl.DataFrame, *, ses: str, + task: str, regressors: Sequence[str] = ("36-parameter",), ) -> dict[str, Path | dict[str, Path]]: """Resolve inputs for longitudinal functional processing. @@ -33,6 +34,7 @@ def resolve_longitudinal_func( func_df: DataFrame of functional derivatives. tpl_df: DataFrame of longitudinal template files. ses: Session label (used for template xfm lookup). + task: Task entity value to denote BOLD reference for template resampling. regressors: Regressor strategy names to resolve raw regressor files for. @@ -50,7 +52,6 @@ def resolve_longitudinal_func( without=["space"], ) - task = func_df["task"].unique()[0] return { "template": tpl_q.expect(tpl_df, suffix="T1w", res=task), "anat_to_template_xfm": tpl_q.expect( diff --git a/src/rbc/bids/longitudinal/template.py b/src/rbc/bids/longitudinal/template.py index bde54a6a..6cb25b9a 100644 --- a/src/rbc/bids/longitudinal/template.py +++ b/src/rbc/bids/longitudinal/template.py @@ -80,7 +80,21 @@ def discover_template_inputs( # Filter for first found session; only single reference per task is needed sub_bold = bold_rows.filter( (pl.col("sub") == sub) & (pl.col("ses") == sessions[0]) - ) + ).unique(subset=("task", "root", "path")) + # Check each task is unique, otherwise raise assertion error with details + if sub_bold.height != sub_bold["task"].n_unique(): + conflicts = ( + sub_bold.filter(pl.col("task").is_duplicated()) + .group_by("task") + .agg(pl.format("{}/{}", "root", "path").alias("paths")) + ) + raise AssertionError( + f"Found multiple non-matching grids for subject {sub}:\n" + + "\n".join( + f"Task '{row['task']}': {row['paths']}" + for row in conflicts.iter_rows(named=True) + ) + ) bold_files = { row["task"]: Path(row["root"]) / row["path"] for row in sub_bold.iter_rows(named=True) diff --git a/src/rbc/core/longitudinal/resampling.py b/src/rbc/core/longitudinal/resampling.py index a2e87a1f..ace508d8 100644 --- a/src/rbc/core/longitudinal/resampling.py +++ b/src/rbc/core/longitudinal/resampling.py @@ -13,12 +13,13 @@ from rbc.core.niwrap import generate_exec_folder -def resample_img_to_bold_grid(bold_ref: Path, img: Path) -> Path: +def resample_img_to_bold_grid(bold_ref: Path, img: Path, order: int = 3) -> Path: """Resample template to BOLD grid if shapes differ. Args: bold_ref: BOLD reference volume. img: 3D image in target space to resample. + order: Interpolation order used during resampling Returns: Resampled 3D image with BOLD grid @@ -33,7 +34,7 @@ def resample_img_to_bold_grid(bold_ref: Path, img: Path) -> Path: if bold_ref_img.shape == img_obj.shape: return img - img_resampled = resample_from_to(img_obj, bold_ref_img) + img_resampled = resample_from_to(img_obj, bold_ref_img, order=order) img_resampled_path = ( generate_exec_folder("img_resample_to_bold_grid") / "resampled.nii.gz" ) diff --git a/src/rbc/orchestration/longitudinal/functional.py b/src/rbc/orchestration/longitudinal/functional.py index dc971b38..c61e1628 100644 --- a/src/rbc/orchestration/longitudinal/functional.py +++ b/src/rbc/orchestration/longitudinal/functional.py @@ -64,6 +64,7 @@ def process_func( func_df, tpl_df, ses=pipe_ctx.ses, # type: ignore[arg-type] + task=ents["task"], regressors=regressors, ) func_outputs = functional_longitudinal(**resolved) # type: ignore[arg-type] diff --git a/src/rbc/orchestration/longitudinal/qc.py b/src/rbc/orchestration/longitudinal/qc.py index df597657..aecda392 100644 --- a/src/rbc/orchestration/longitudinal/qc.py +++ b/src/rbc/orchestration/longitudinal/qc.py @@ -71,7 +71,7 @@ def process_qc( # Resample longitudinal anatomical mask to bold grid for QC purposes. # Longitudinal processed data are registered to the longitudinal template with # respective modality's native resolution - anat_brain_mask = resample_img_to_bold_grid(bold_mask, anat_brain_mask) + anat_brain_mask = resample_img_to_bold_grid(bold_mask, anat_brain_mask, order=0) anat_mask_arr = nib.nifti1.load(anat_brain_mask).get_fdata() bold_mask_arr = nib.nifti1.load(bold_mask).get_fdata() reg_metrics = registration_qc_metrics(anat_mask_arr, bold_mask_arr) diff --git a/src/rbc/workflows/longitudinal/template.py b/src/rbc/workflows/longitudinal/template.py index 5951a405..24474280 100644 --- a/src/rbc/workflows/longitudinal/template.py +++ b/src/rbc/workflows/longitudinal/template.py @@ -28,7 +28,8 @@ class LongitudinalTemplateOutputs(NamedTuple): Attributes: template: Robust within-subject template volume. - bold_template: Within-subject template volume resampled to BOLD resolution. + bold_templates: Within-subject template volumes resampled to task-specific BOLD + resolutions. sessions: Session labels in the same order as ``transforms``. transforms: Per-session ITK-format session-to-template transforms. """ diff --git a/tests/unit/bids/test_longitudinal_functional.py b/tests/unit/bids/test_longitudinal_functional.py index 960cf96b..fece77c2 100644 --- a/tests/unit/bids/test_longitudinal_functional.py +++ b/tests/unit/bids/test_longitudinal_functional.py @@ -176,6 +176,7 @@ def test_resolves_single_regressor(self, tmp_path: Path) -> None: func_df, tpl_df, ses="baseline", + task="rest", regressors=["36-parameter"], ) @@ -258,6 +259,7 @@ def test_resolves_multiple_regressors(self, tmp_path: Path) -> None: func_df, tpl_df, ses="baseline", + task="rest", regressors=["36-parameter", "aCompCor"], ) @@ -322,6 +324,7 @@ def test_missing_regressor_raises(self, tmp_path: Path) -> None: func_df, tpl_df, ses="baseline", + task="rest", regressors=["aCompCor"], ) @@ -379,6 +382,7 @@ def test_bold_mask_mandatory(self, tmp_path: Path) -> None: func_df, tpl_df, ses="baseline", + task="rest", regressors=["36-parameter"], ) From 35ad7b1ecc1ad589eaad38d4c310d45c6cf55ac3 Mon Sep 17 00:00:00 2001 From: Jason Kai <21226986+kaitj@users.noreply.github.com> Date: Mon, 18 May 2026 09:51:19 -0400 Subject: [PATCH 10/12] Extract row entities to find refs + file naming --- src/rbc/bids/longitudinal/template.py | 16 ++++------ src/rbc/orchestration/longitudinal/all.py | 4 +-- .../orchestration/longitudinal/anatomical.py | 2 +- .../orchestration/longitudinal/functional.py | 4 +-- tests/unit/bids/test_longitudinal_template.py | 29 ++++++++++++++++++- .../test_longitudinal_template.py | 10 +++++++ 6 files changed, 49 insertions(+), 16 deletions(-) diff --git a/src/rbc/bids/longitudinal/template.py b/src/rbc/bids/longitudinal/template.py index 6cb25b9a..1e0a243f 100644 --- a/src/rbc/bids/longitudinal/template.py +++ b/src/rbc/bids/longitudinal/template.py @@ -7,7 +7,7 @@ import polars as pl -from rbc.bids import Suffix, bids_safe_label +from rbc.bids import FUNC_GROUP_ENTITIES, Suffix, bids_safe_label if TYPE_CHECKING: from rbc.bids import Bids @@ -77,23 +77,19 @@ def discover_template_inputs( files = [ Path(row["root"]) / row["path"] for row in sub_group.iter_rows(named=True) ] - # Filter for first found session; only single reference per task is needed sub_bold = bold_rows.filter( (pl.col("sub") == sub) & (pl.col("ses") == sessions[0]) - ).unique(subset=("task", "root", "path")) + ).unique(subset=(*FUNC_GROUP_ENTITIES, "root", "path")) # Check each task is unique, otherwise raise assertion error with details - if sub_bold.height != sub_bold["task"].n_unique(): + if sub_bold.height != sub_bold.unique().height: conflicts = ( - sub_bold.filter(pl.col("task").is_duplicated()) - .group_by("task") + sub_bold.filter(pl.struct(FUNC_GROUP_ENTITIES).is_duplicated()) + .group_by(FUNC_GROUP_ENTITIES) .agg(pl.format("{}/{}", "root", "path").alias("paths")) ) raise AssertionError( f"Found multiple non-matching grids for subject {sub}:\n" - + "\n".join( - f"Task '{row['task']}': {row['paths']}" - for row in conflicts.iter_rows(named=True) - ) + + "\n".join(str(dict(row)) for row in conflicts.iter_rows(named=True)) ) bold_files = { row["task"]: Path(row["root"]) / row["path"] diff --git a/src/rbc/orchestration/longitudinal/all.py b/src/rbc/orchestration/longitudinal/all.py index bd8902d0..6bc0b1d2 100644 --- a/src/rbc/orchestration/longitudinal/all.py +++ b/src/rbc/orchestration/longitudinal/all.py @@ -18,7 +18,7 @@ from rbc.bids import FUNC_GROUP_ENTITIES, Datatype, Suffix, extract_entities, load_table from rbc.bids.longitudinal.template import discover_template_inputs from rbc.bids.metrics import export_metrics -from rbc.bids.session import iter_session_files +from rbc.bids.session import _FUNC_ENTITY_KEYS, iter_session_files from rbc.context import RunContext from rbc.orchestration import Filters, RunnerConfig, init_runner from rbc.orchestration.longitudinal._iter import iter_sessions_with_template @@ -124,7 +124,7 @@ def run( ) row = func_df.filter(suffix=Suffix.BOLD).row(0, named=True) - ents = extract_entities(row, ["task", "run"]) + ents = extract_entities(row, _FUNC_ENTITY_KEYS) func_q = pipe_ctx.bids(datatype=Datatype.FUNC, entities=ents) func_long = func_q.derive(space="longitudinal") diff --git a/src/rbc/orchestration/longitudinal/anatomical.py b/src/rbc/orchestration/longitudinal/anatomical.py index 509116d1..8a5ccfc0 100644 --- a/src/rbc/orchestration/longitudinal/anatomical.py +++ b/src/rbc/orchestration/longitudinal/anatomical.py @@ -51,7 +51,7 @@ def process_anat( Workflow outputs for in-memory handoff to downstream stages. """ anat_df = anat_df.filter(pl.col("space").is_null()) - ents = extract_entities(anat_df.row(0, named=True), ["run"]) + ents = extract_entities(anat_df.row(0, named=True), ["run", "acq", "rec", "echo"]) anat_q = pipe_ctx.bids(datatype=Datatype.ANAT) tpl_q = anat_q.derive(ses="longitudinal") diff --git a/src/rbc/orchestration/longitudinal/functional.py b/src/rbc/orchestration/longitudinal/functional.py index c61e1628..913084da 100644 --- a/src/rbc/orchestration/longitudinal/functional.py +++ b/src/rbc/orchestration/longitudinal/functional.py @@ -10,7 +10,7 @@ export_longitudinal_func, resolve_longitudinal_func, ) -from rbc.bids.session import iter_session_files +from rbc.bids.session import _FUNC_ENTITY_KEYS, iter_session_files from rbc.orchestration import Filters, RunnerConfig, init_runner from rbc.orchestration.longitudinal._iter import iter_sessions_with_template from rbc.workflows.longitudinal.functional import ( @@ -53,7 +53,7 @@ def process_func( Workflow outputs for in-memory handoff to downstream stages. """ row = func_df.filter(suffix=Suffix.BOLD).row(0, named=True) - ents = extract_entities(row, ["task", "run"]) + ents = extract_entities(row, list(_FUNC_ENTITY_KEYS)) func_q = pipe_ctx.bids(datatype=Datatype.FUNC, entities=ents) tpl_q = pipe_ctx.bids(datatype=Datatype.ANAT).derive(ses="longitudinal") diff --git a/tests/unit/bids/test_longitudinal_template.py b/tests/unit/bids/test_longitudinal_template.py index 8ff7031a..71bfe5cb 100644 --- a/tests/unit/bids/test_longitudinal_template.py +++ b/tests/unit/bids/test_longitudinal_template.py @@ -23,6 +23,11 @@ "sub", "ses", "space", + "acq", + "dir", + "echo", + "part", + "rec", "task", "run", "desc", @@ -50,6 +55,11 @@ def _anat_row(sub: str, ses: str, space: str | None = None) -> tuple: space, None, None, + None, + None, + None, + None, + None, "brain", "/data", path, @@ -58,7 +68,24 @@ def _anat_row(sub: str, ses: str, space: str | None = None) -> tuple: def _func_row(sub: str, ses: str, task: str = "rest") -> tuple: path = f"sub-{sub}/ses-{ses}/func/sub-{sub}_ses-{ses}_task-{task}_bold.nii.gz" - return ("func", "bold", ".nii.gz", sub, ses, None, task, None, None, "/data", path) + return ( + "func", + "bold", + ".nii.gz", + sub, + ses, + None, + None, + None, + None, + None, + None, + task, + None, + None, + "/data", + path, + ) class TestDiscoverTemplateInputs: diff --git a/tests/unit/orchestration/test_longitudinal_template.py b/tests/unit/orchestration/test_longitudinal_template.py index c2b6af28..5cdf8b2f 100644 --- a/tests/unit/orchestration/test_longitudinal_template.py +++ b/tests/unit/orchestration/test_longitudinal_template.py @@ -23,6 +23,11 @@ "sub", "ses", "space", + "acq", + "dir", + "echo", + "part", + "rec", "task", "run", "desc", @@ -42,6 +47,11 @@ def _brain_row(sub: str, ses: str) -> tuple: None, None, None, + None, + None, + None, + None, + None, "brain", "/data", path, From 0cdaed6bb1b9ce1990acb0337e010918b1cf0371 Mon Sep 17 00:00:00 2001 From: Jason Kai <21226986+kaitj@users.noreply.github.com> Date: Tue, 19 May 2026 10:15:07 -0400 Subject: [PATCH 11/12] Address reviews, round 2 - Removed check for uniqueness of bold dataframe - Update stale docstring: `bold_ref` -> `bold_files` - Wrap task with `bids_safe_label` when calling `tpl.save` - Compare affines using `np.allclose` due to floating point precision - Use a BoldKey NamedTuple as key to unique bold files --- src/rbc/bids/longitudinal/template.py | 47 ++++++++++++------- src/rbc/core/longitudinal/resampling.py | 5 +- src/rbc/orchestration/longitudinal/qc.py | 4 +- src/rbc/workflows/longitudinal/template.py | 6 ++- tests/unit/bids/test_longitudinal_template.py | 3 +- 5 files changed, 42 insertions(+), 23 deletions(-) diff --git a/src/rbc/bids/longitudinal/template.py b/src/rbc/bids/longitudinal/template.py index 1e0a243f..cb8ad387 100644 --- a/src/rbc/bids/longitudinal/template.py +++ b/src/rbc/bids/longitudinal/template.py @@ -21,13 +21,36 @@ class TemplateInputs(NamedTuple): sub: Subject label. sessions: Per-input session labels (parallel to ``files``). files: Per-session preprocessed T1w brain volumes. - bold_ref: Per-session preprocessed BOLD volumes (all tasks). + bold_files: Per-session, unique preprocessed BOLD volumes (all tasks). """ sub: str sessions: list[str] files: list[Path] - bold_files: dict[str, Path] + bold_files: dict[BoldKey, Path] + + +# Use typing library instead of collections for static type hints +class BoldKey(NamedTuple): + """Key with associated entities to use as dict key to represent bold filepath found. + + Attributes: + task: Task associated with file. + run: Run associated with file. + acq: Acquisition associated with file. + dir: Direction associated with file. + echo: Echo associated with file. + part: Part associated with file. + rec: Recording associated with file. + """ + + task: str + run: str | None = None + acq: str | None = None + dir: str | None = None + echo: str | None = None + part: str | None = None + rec: str | None = None def discover_template_inputs( @@ -80,19 +103,9 @@ def discover_template_inputs( sub_bold = bold_rows.filter( (pl.col("sub") == sub) & (pl.col("ses") == sessions[0]) ).unique(subset=(*FUNC_GROUP_ENTITIES, "root", "path")) - # Check each task is unique, otherwise raise assertion error with details - if sub_bold.height != sub_bold.unique().height: - conflicts = ( - sub_bold.filter(pl.struct(FUNC_GROUP_ENTITIES).is_duplicated()) - .group_by(FUNC_GROUP_ENTITIES) - .agg(pl.format("{}/{}", "root", "path").alias("paths")) - ) - raise AssertionError( - f"Found multiple non-matching grids for subject {sub}:\n" - + "\n".join(str(dict(row)) for row in conflicts.iter_rows(named=True)) - ) - bold_files = { - row["task"]: Path(row["root"]) / row["path"] + bold_files: dict[BoldKey, Path] = { + BoldKey(**{ent: row[ent] for ent in FUNC_GROUP_ENTITIES}): Path(row["root"]) + / row["path"] for row in sub_bold.iter_rows(named=True) } inputs.append( @@ -112,8 +125,8 @@ def export_template(tpl: Bids, outputs: LongitudinalTemplateOutputs) -> None: outputs: Results from the longitudinal template workflow. """ tpl.save(outputs.template, suffix=Suffix.T1W) - for btask, bold_template in outputs.bold_templates.items(): - tpl.save(bold_template, res=btask, suffix=Suffix.T1W) + for bold_key, bold_template in outputs.bold_templates.items(): + tpl.save(bold_template, res=bids_safe_label(bold_key.task), suffix=Suffix.T1W) for ses, xfm in zip(outputs.sessions, outputs.transforms, strict=True): ses_label = bids_safe_label(ses) tpl.save( diff --git a/src/rbc/core/longitudinal/resampling.py b/src/rbc/core/longitudinal/resampling.py index ace508d8..0c1fdde6 100644 --- a/src/rbc/core/longitudinal/resampling.py +++ b/src/rbc/core/longitudinal/resampling.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING import nibabel as nib +import numpy as np from nibabel.processing import resample_from_to if TYPE_CHECKING: @@ -31,7 +32,9 @@ def resample_img_to_bold_grid(bold_ref: Path, img: Path, order: int = 3) -> Path if len(bold_ref_img.shape) > 3: bold_ref_img = bold_ref_img.slicer[..., 0] # If same shape, no need to resample - if bold_ref_img.shape == img_obj.shape: + if bold_ref_img.shape == img_obj.shape and np.allclose( + bold_ref_img.affine, img_obj.affine + ): return img img_resampled = resample_from_to(img_obj, bold_ref_img, order=order) diff --git a/src/rbc/orchestration/longitudinal/qc.py b/src/rbc/orchestration/longitudinal/qc.py index aecda392..33e55dab 100644 --- a/src/rbc/orchestration/longitudinal/qc.py +++ b/src/rbc/orchestration/longitudinal/qc.py @@ -20,7 +20,7 @@ resolve_longitudinal_qc, write_longitudinal_qc_tsv, ) -from rbc.bids.session import iter_session_files +from rbc.bids.session import _FUNC_ENTITY_KEYS, iter_session_files from rbc.core.longitudinal.resampling import resample_img_to_bold_grid from rbc.core.niwrap import generate_exec_folder from rbc.core.qc.registration import registration_qc_metrics @@ -160,7 +160,7 @@ def run( for func_df, _ in iter_session_files(session, groupby=FUNC_GROUP_ENTITIES): row = func_df.filter(suffix=Suffix.BOLD).row(0, named=True) - ents = extract_entities(row, ["task", "run"]) + ents = extract_entities(row, _FUNC_ENTITY_KEYS) func_q = pipe_ctx.bids(datatype=Datatype.FUNC, entities=ents) func_long_q = func_q.derive(space="longitudinal") diff --git a/src/rbc/workflows/longitudinal/template.py b/src/rbc/workflows/longitudinal/template.py index 24474280..7225749e 100644 --- a/src/rbc/workflows/longitudinal/template.py +++ b/src/rbc/workflows/longitudinal/template.py @@ -20,6 +20,8 @@ from collections.abc import Mapping, Sequence from pathlib import Path + from rbc.bids.longitudinal.template import BoldKey + _logger = logging.getLogger("rbc") @@ -35,7 +37,7 @@ class LongitudinalTemplateOutputs(NamedTuple): """ template: Path - bold_templates: dict[str, Path] + bold_templates: dict[BoldKey, Path] sessions: list[str] transforms: list[Path] @@ -44,7 +46,7 @@ def generate_subject_template( sub: str, sessions: Sequence[str], in_files: Sequence[Path], - bold_files: Mapping[str, Path], + bold_files: Mapping[BoldKey, Path], ) -> LongitudinalTemplateOutputs: """Build a robust template and ITK transforms for one subject. diff --git a/tests/unit/bids/test_longitudinal_template.py b/tests/unit/bids/test_longitudinal_template.py index 71bfe5cb..25f3a4e5 100644 --- a/tests/unit/bids/test_longitudinal_template.py +++ b/tests/unit/bids/test_longitudinal_template.py @@ -7,6 +7,7 @@ import polars as pl from rbc.bids.longitudinal.template import ( + BoldKey, discover_template_inputs, export_template, ) @@ -182,7 +183,7 @@ def test_writes_template_and_xfms(self, tmp_path: Path) -> None: outputs = LongitudinalTemplateOutputs( template=template_src, - bold_templates={"test": template_bold_src}, + bold_templates={BoldKey(task="test"): template_bold_src}, sessions=["baseline", "vis2"], transforms=[xfm_baseline, xfm_vis2], ) From b0ca8efb090dedbc420c7478119362ea0abab3bd Mon Sep 17 00:00:00 2001 From: Jason Kai <21226986+kaitj@users.noreply.github.com> Date: Wed, 20 May 2026 11:44:38 -0400 Subject: [PATCH 12/12] Apply session-to-longitudinal template xfm --- src/rbc/core/longitudinal/transform.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/rbc/core/longitudinal/transform.py b/src/rbc/core/longitudinal/transform.py index 7a147271..e6b3947d 100644 --- a/src/rbc/core/longitudinal/transform.py +++ b/src/rbc/core/longitudinal/transform.py @@ -36,6 +36,7 @@ def anat_transform(in_file: Path, template: Path, xfm: Path) -> Path: return ants.ants_apply_transforms( reference_image=template, input_image=in_file, + transform=[ants.ants_apply_transforms_transform_file_name(xfm)], output=ants.ants_apply_transforms_warped_output("subject_to_template.nii.gz"), dimensionality=3, interpolation=ants.ants_apply_transforms_linear(),