Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/rbc/bids/functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,12 @@ def export_functional(
desc=bids_safe_label(reg),
extension=".1D",
)
func.save(
outputs.bpf_regressor_file[reg],
suffix="regressors+filtered",
desc=bids_safe_label(reg),
extension=".1D",
)

mni = func.derive(space=TemplateSpace.MNI152NLIN6ASYM)
for reg in regressors:
Expand Down
46 changes: 40 additions & 6 deletions src/rbc/bids/longitudinal.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

from __future__ import annotations

from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, TypedDict

from rbc.bids import Suffix, TemplateSpace
from rbc.bids import Suffix, TemplateSpace, bids_safe_label

if TYPE_CHECKING:
from collections.abc import Sequence
from pathlib import Path

import polars as pl
Expand Down Expand Up @@ -107,14 +108,27 @@ def export_longitudinal_anat(aex: Bids, outputs: AnatomicalLongOutputs) -> None:
)


class LongitudinalFuncInputs(TypedDict):
"""Resolved inputs for the longitudinal functional workflow."""

template: Path
anat_to_template_xfm: Path
bold_to_anat_itk: Path
sbref: Path
bold: Path
bold_mask: Path
regressor_files: dict[str, Path]


def resolve_longitudinal_func(
func_q: Bids,
tpl_q: Bids,
func_df: pl.DataFrame,
tpl_df: pl.DataFrame,
*,
ses: str,
) -> dict[str, Path | None]:
regressors: Sequence[str],
) -> LongitudinalFuncInputs:
"""Resolve inputs for longitudinal functional processing.

Args:
Expand All @@ -123,6 +137,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).
regressors: Regressor names (e.g. ``["36-parameter"]``).

Returns:
Dict with keys matching ``longitudinal_process`` parameters.
Expand All @@ -146,9 +161,15 @@ def resolve_longitudinal_func(
"bold": func_q.expect(
func_df, suffix=Suffix.BOLD, desc="preproc", without=["space"]
),
"bold_mask": func_q.find(
"bold_mask": func_q.expect(
func_df, suffix=Suffix.MASK, desc="brain", without=["space"]
),
"regressor_files": {
reg: func_q.expect(
func_df, suffix="regressors", desc=bids_safe_label(reg), extension=".1D"
)
for reg in regressors
},
}


Expand All @@ -167,5 +188,18 @@ def export_longitudinal_func(fex: Bids, outputs: FunctionalLongOutputs) -> None:
desc="composite",
extra={"from": "bold", "to": "longitudinal", "mode": "image"},
)
if outputs.bold_mask:
fex.save(outputs.bold_mask, suffix=Suffix.MASK, desc="brain")
fex.save(outputs.bold_mask, suffix=Suffix.MASK, desc="brain")
for reg, path in outputs.regressed_bold.items():
fex.save(
path,
suffix=Suffix.BOLD,
desc="regressed",
extra={"reg": bids_safe_label(reg)},
)
for reg, path in outputs.cleaned_bold.items():
fex.save(
path,
suffix=Suffix.BOLD,
desc="preproc",
extra={"reg": bids_safe_label(reg)},
)
12 changes: 11 additions & 1 deletion src/rbc/cli/longitudinal.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Literal

from rbc.cli.base import BaseArgs, _or_default, _validate_nifti_path
from rbc.orchestration import Filters, RunnerConfig
Expand All @@ -23,6 +23,7 @@ class LongitudinalArgs(BaseArgs):
anatomical: bool
functional: bool
registration_template: Path
regressor: Sequence[Literal["36-parameter", "aCompCor"]]

@classmethod
def validate_namespace(cls, ns: argparse.Namespace) -> LongitudinalArgs:
Expand All @@ -38,6 +39,7 @@ def validate_namespace(cls, ns: argparse.Namespace) -> LongitudinalArgs:
registration_template=_or_default(
ns.anat_template, REGISTRATION_TEMPLATES.brain_1mm
),
regressor=ns.regressor,
)


Expand All @@ -53,6 +55,7 @@ def main(args: LongitudinalArgs) -> int:
anatomical=args.anatomical,
functional=args.functional,
registration_template=args.registration_template,
regressors=args.regressor,
runner_config=RunnerConfig(
runner=args.runner,
verbose=bool(args.verbose),
Expand Down Expand Up @@ -86,6 +89,13 @@ def register_command(
action="store_true",
help="Use functional longitudinal pipeline for processing",
)
parser.add_argument(
"--regressor",
nargs="+",
choices=["36-parameter", "aCompCor"],
default=["36-parameter"],
help="Space-delimited nuisance regression method(s) to apply.",
)

templates = parser.add_argument_group("template overrides")
templates.add_argument(
Expand Down
15 changes: 13 additions & 2 deletions src/rbc/orchestration/longitudinal.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
load_table,
)
from rbc.bids.longitudinal import (
LongitudinalFuncInputs,
export_longitudinal_anat,
export_longitudinal_func,
resolve_longitudinal_anat,
Expand Down Expand Up @@ -75,26 +76,29 @@ def process_func(
pipe_ctx: RunContext,
func_df: pl.DataFrame,
tpl_df: pl.DataFrame,
regressors: Sequence[str],
) -> None:
"""Handle functional longitudinal processing for one BOLD run.

Args:
pipe_ctx: RunContext bound to this subject/session.
func_df: Functional derivative DataFrame for this run.
tpl_df: Longitudinal template DataFrame.
regressors: Regressor names (e.g. ``["36-parameter"]``).
"""
row = func_df.filter(suffix=Suffix.BOLD).row(0, named=True)
ents = extract_entities(row, ["task", "run"])

func_q = pipe_ctx.bids(datatype=Datatype.FUNC, entities=ents)
tpl_q = pipe_ctx.bids(datatype=Datatype.ANAT).derive(ses="longitudinal")

resolved = resolve_longitudinal_func(
resolved: LongitudinalFuncInputs = resolve_longitudinal_func(
func_q,
tpl_q,
func_df,
tpl_df,
ses=pipe_ctx.ses, # type: ignore[arg-type]
regressors=regressors,
)
func_outputs = functional_longitudinal(**resolved) # type: ignore[arg-type]
fex = func_q.derive(space="longitudinal")
Expand All @@ -109,6 +113,7 @@ def run(
anatomical: bool = True,
functional: bool = True,
registration_template: Path = REGISTRATION_TEMPLATES.brain_1mm,
regressors: Sequence[str] = ("36-parameter",),
runner_config: RunnerConfig | None = None,
) -> None:
"""Run the longitudinal pipeline for all matching subjects/sessions.
Expand All @@ -120,6 +125,7 @@ def run(
anatomical: Run anatomical longitudinal processing.
functional: Run functional longitudinal processing.
registration_template: Brain template for ANTs registration.
regressors: Nuisance regressor strategies to apply (e.g. ``["36-parameter"]``).
runner_config: Execution backend configuration.
"""
config = runner_config or RunnerConfig()
Expand Down Expand Up @@ -173,7 +179,12 @@ def run(

if functional:
for func_df, _ in iter_session_files(session, groupby=FUNC_GROUP_ENTITIES):
process_func(pipe_ctx=pipe_ctx, func_df=func_df, tpl_df=tpl_df)
process_func(
pipe_ctx=pipe_ctx,
func_df=func_df,
tpl_df=tpl_df,
regressors=regressors,
)

pipe_ctx.ensure_dataset_description()

Expand Down
82 changes: 66 additions & 16 deletions src/rbc/workflows/functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ class FunctionalOutputs(NamedTuple):
template_bold: BOLD resampled to template space.
regressed_bold: Nuisance-regressed & non-bandpassed BOLD.
cleaned_bold: Nuisance-regressed & bandpass-filtered BOLD.
regressor_file: Bandpass-filtered nuisance regressor ``.1D`` file.
regressor_file: Nuisance regressor ``.1D`` files.
bpf_regressor_file: Bandpass-filtered nuisance regressor ``.1D`` files.
template_brain_mask: Brain mask warped to template space.
"""

Expand All @@ -105,6 +106,7 @@ class FunctionalOutputs(NamedTuple):
regressed_bold: dict[str, Path]
cleaned_bold: dict[str, Path]
regressor_file: dict[str, Path]
bpf_regressor_file: dict[str, Path]
template_brain_mask: Path


Expand Down Expand Up @@ -378,7 +380,8 @@ def single_session_preprocess(
template_bold=template_bold,
regressed_bold={r: regression[r].regressed_bold for r in regressor_set},
cleaned_bold={r: cleaned[r].regressed_bold for r in regressor_set},
regressor_file=filtered_regressors,
regressor_file={r: regressors[r].regressor_file for r in regressor_set},
bpf_regressor_file=filtered_regressors,
template_brain_mask=tmpl_brain,
)

Expand All @@ -390,14 +393,19 @@ class FunctionalLongOutputs(NamedTuple):
bold_to_long_xfm: BOLD-to-longitudinal-template composite warp.
sbref: Motion reference volume warped to longitudinal template space.
bold: Preprocessed BOLD warped to longitudinal template space.
bold_mask: Brain mask warped to longitudinal template space,
or *None* if no mask was provided.
bold_mask: Brain mask warped to longitudinal template space.
regressed_bold: Nuisance-regressed (non-bandpassed) BOLD in longitudinal
template space. Suitable for ALFF/fALFF.
cleaned_bold: Nuisance-regressed + bandpass-filtered BOLD in longitudinal
template space (Hallquist 2013).
"""

bold_to_long_xfm: Path
sbref: Path
bold: Path
bold_mask: Path | None = None
bold_mask: Path
regressed_bold: dict[str, Path]
cleaned_bold: dict[str, Path]


def longitudinal_process(
Expand All @@ -407,41 +415,83 @@ def longitudinal_process(
bold_to_anat_itk: Path,
sbref: Path,
bold: Path,
bold_mask: Path | None,
bold_mask: Path,
regressor_files: dict[str, Path],
) -> FunctionalLongOutputs:
"""Transform preprocessed functional outputs to longitudinal template space.

Assumes a longitudinal template has been generated, the subject-to-template
composite warp is available, and anatomical data has already been processed
to longitudinal template space.

Regressors are computed once during cross-sectional preprocessing and passed
in via ``regressor_files``. Only the regression steps are re-run against the
longitudinal space BOLD.

Steps:
1. Compose BOLD-to-anatomical + anatomical-to-longitudinal-template transforms.
2. Warp sbref (3D) and preproc BOLD (4D) to longitudinal template space.
3. Warp brain mask to longitudinal template space.
4. Nuisance regression without bandpass on longitudinal-space BOLD
(for ALFF/fALFF).
5. Nuisance regression with simultaneous bandpass filtering on longitudinal-space
BOLD (Hallquist 2013).

Args:
template: Longitudinal template image.
anat_to_template_xfm: T1w-to-longitudinal-template composite warp.
bold_to_anat_itk: BOLD-to-T1w affine in ITK format.
sbref: Motion reference (single-band reference) volume.
bold: Preprocessed bold image.
bold_mask: Bold brain mask, if available.
bold_mask: Bold brain mask in native space.
regressor_files: Per-regressor nuisance regressor ``.1D`` files.


Returns:
:class:`FunctionalLongOutputs` with all non-null inputs transformed to template
space.
"""
# 1. Compose full BOLD -> longitudinal template transform
bold_to_tpl_xfm = compose_transform(
ref=template,
bold_to_anat_itk=bold_to_anat_itk,
anat_to_tpl_xfm=anat_to_template_xfm,
)

# 2. Warp sbref & bold to longitudinal space
warped_sbref = func_transform(
in_file=sbref, template=template, xfm=bold_to_tpl_xfm, strategy="single"
)
warped_bold = func_transform(
in_file=bold, template=template, xfm=bold_to_tpl_xfm, strategy="chunked"
)

# 3. Warp bold mask to longitudinal space
warped_mask = mask_transform(mask=bold_mask, template=template, xfm=bold_to_tpl_xfm)

regression: dict[str, ApplyRegressionOutputs] = {}
cleaned: dict[str, ApplyRegressionOutputs] = {}
for reg, reg_file in regressor_files.items():
# 4. Nuisance regression without bandpass
_logger.info("%s nuisance regression (no bandpass)", reg)
regression[reg] = apply_regression(
bold_file=warped_bold,
brain_mask_file=warped_mask,
regressor_file=reg_file,
)
# 5. Simultaneous regression + bandpass filtering (Hallquist 2013)
_logger.info("%s nuisance regression + bandpass filtering", reg)
cleaned[reg] = apply_regression_bandpass(
bold_file=warped_bold,
brain_mask_file=warped_mask,
regressor_file=reg_file,
)

return FunctionalLongOutputs(
sbref=func_transform( # 3D volume
in_file=sbref, template=template, xfm=bold_to_tpl_xfm, strategy="single"
),
bold=func_transform(
in_file=bold, template=template, xfm=bold_to_tpl_xfm, strategy="chunked"
),
bold_mask=mask_transform(mask=bold_mask, template=template, xfm=bold_to_tpl_xfm)
if bold_mask
else None,
bold_to_long_xfm=bold_to_tpl_xfm,
sbref=warped_sbref,
bold=warped_bold,
bold_mask=warped_mask,
regressed_bold={r: regression[r].regressed_bold for r in regressor_files},
cleaned_bold={r: cleaned[r].regressed_bold for r in regressor_files},
)
5 changes: 3 additions & 2 deletions tests/unit/bids/test_exports.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ def _make_func_outputs(w: Path, regressors: list[str]) -> FunctionalOutputs:
regressed_bold={r: _dummy(w, f"regressed_{r}.nii.gz") for r in regressors},
cleaned_bold={r: _dummy(w, f"cleaned_{r}.nii.gz") for r in regressors},
regressor_file={r: _dummy(w, f"regressors_{r}.1D") for r in regressors},
bpf_regressor_file={r: _dummy(w, f"bpf_regressors_{r}.1D") for r in regressors},
template_brain_mask=_dummy(w, "template_mask.nii.gz"),
)

Expand Down Expand Up @@ -201,7 +202,7 @@ def test_file_count_single_regressor(
outputs = _make_func_outputs(workdir, ["36-parameter"])
export_functional(func_bids, outputs, regressors=["36-parameter"])
saved = list(pipe_ctx.output_dir.rglob("*.*"))
assert len(saved) == 13
assert len(saved) == 14

def test_file_count_two_regressors(
self, func_bids: Bids, workdir: Path, pipe_ctx: RunContext
Expand All @@ -214,7 +215,7 @@ def test_file_count_two_regressors(
outputs = _make_func_outputs(workdir, regs)
export_functional(func_bids, outputs, regressors=regs)
saved = list(pipe_ctx.output_dir.rglob("*.*"))
assert len(saved) == 16
assert len(saved) == 18


# ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions tests/unit/cli/test_longitudinal.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ def base_args(tmp_path: Path) -> argparse.Namespace:
tmp_dir=None,
anat_template=None,
ants_threads=1,
regressor=["36-parameter"],
)


Expand Down
Loading