Skip to content
2 changes: 1 addition & 1 deletion src/rbc/bids/longitudinal/anatomical.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/rbc/bids/longitudinal/functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 18 additions & 1 deletion src/rbc/bids/longitudinal/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
kaitj marked this conversation as resolved.
Outdated
"""

sub: str
sessions: list[str]
files: list[Path]
bold_ref: Path | None


def discover_template_inputs(
Expand Down Expand Up @@ -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(),
)
Comment thread
kaitj marked this conversation as resolved.
Outdated
bold_ref = (
None
if bold_ref_rows.is_empty()
else Path(bold_ref_rows["root"][0]) / bold_ref_rows["path"][0]
)
Comment thread
kaitj marked this conversation as resolved.
Outdated

inputs: list[TemplateInputs] = []
skipped: list[str] = []
Expand All @@ -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


Expand All @@ -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(
Expand Down
45 changes: 45 additions & 0 deletions src/rbc/core/longitudinal/resampling.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Resampling utilities 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_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).
Comment thread
kaitj marked this conversation as resolved.
Outdated
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.
Comment thread
kaitj marked this conversation as resolved.
Outdated
"""
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]
Comment thread
kaitj marked this conversation as resolved.
Outdated
# If same shape, no need to resample
if bold_ref_img.shape == img_obj.shape:
return img
Comment thread
kaitj marked this conversation as resolved.
Outdated

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(img_resampled, img_resampled_path)
return img_resampled_path
5 changes: 5 additions & 0 deletions src/rbc/orchestration/longitudinal/qc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Comment thread
kaitj marked this conversation as resolved.
Outdated
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)
Expand Down
1 change: 1 addition & 0 deletions src/rbc/orchestration/longitudinal/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions src/rbc/workflows/longitudinal/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
fs_to_itk_xfm,
generate_robust_template,
)
from rbc.core.longitudinal.resampling import resample_img_to_bold_grid

if TYPE_CHECKING:
from collections.abc import Sequence
Expand All @@ -27,11 +28,13 @@ class LongitudinalTemplateOutputs(NamedTuple):

Attributes:
template: Robust within-subject template volume.
bold_template: Within-subject template volume resampled to BOLD resolution.
Comment thread
kaitj marked this conversation as resolved.
Outdated
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]

Expand All @@ -40,13 +43,15 @@ 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.

Args:
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.
Expand All @@ -65,8 +70,12 @@ 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)
Comment thread
kaitj marked this conversation as resolved.
Outdated

return LongitudinalTemplateOutputs(
template=robust.template,
bold_template=bold_ref,
sessions=list(sessions),
transforms=itk_xfms,
)
1 change: 1 addition & 0 deletions tests/unit/bids/test_longitudinal_anatomical.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ def _anat_row(
"sub": sub,
"ses": ses,
"desc": desc,
"res": None,
"root": "/data",
"path": path,
"extra_entities": extra or [],
Expand Down
10 changes: 6 additions & 4 deletions tests/unit/bids/test_longitudinal_functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -73,6 +74,7 @@ def _anat_row(
"sub": sub,
"ses": ses,
"space": None,
"res": res,
"desc": desc,
"root": "/data",
"path": path,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
48 changes: 33 additions & 15 deletions tests/unit/bids/test_longitudinal_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
Expand All @@ -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"}
Expand All @@ -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"]
Expand All @@ -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
Expand All @@ -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
Expand All @@ -131,13 +146,16 @@ 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"
xfm_vis2.write_text("vis2")

outputs = LongitudinalTemplateOutputs(
template=template_src,
bold_template=template_bold_src,
sessions=["baseline", "vis2"],
transforms=[xfm_baseline, xfm_vis2],
)
Expand Down
Loading