Skip to content

Commit e5dd1af

Browse files
committed
longitudinal func outputs
1 parent 9babdea commit e5dd1af

6 files changed

Lines changed: 121 additions & 21 deletions

File tree

src/rbc/bids/functional.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,12 @@ def export_functional(
158158
desc=bids_safe_label(reg),
159159
extension=".1D",
160160
)
161+
func.save(
162+
outputs.bpf_regressor_file[reg],
163+
suffix="regressorsBPF",
164+
desc=bids_safe_label(reg),
165+
extension=".1D",
166+
)
161167

162168
mni = func.derive(space=TemplateSpace.MNI152NLIN6ASYM)
163169
for reg in regressors:

src/rbc/bids/longitudinal.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44

55
from typing import TYPE_CHECKING
66

7-
from rbc.bids import Suffix
7+
from rbc.bids import Suffix, bids_safe_label
88

99
if TYPE_CHECKING:
10+
from collections.abc import Sequence
1011
from pathlib import Path
1112

1213
import polars as pl
@@ -106,7 +107,8 @@ def resolve_longitudinal_func(
106107
tpl_df: pl.DataFrame,
107108
*,
108109
ses: str,
109-
) -> dict[str, Path | None]:
110+
regressors: Sequence[str],
111+
) -> dict[str, Path | None | dict[str, Path]]:
110112
"""Resolve inputs for longitudinal functional processing.
111113
112114
Args:
@@ -115,6 +117,7 @@ def resolve_longitudinal_func(
115117
func_df: DataFrame of functional derivatives.
116118
tpl_df: DataFrame of longitudinal template files.
117119
ses: Session label (used for template xfm lookup).
120+
regressors: Regressor names (e.g. ``["36-parameter"]``).
118121
119122
Returns:
120123
Dict with keys matching ``longitudinal_process`` parameters.
@@ -138,9 +141,15 @@ def resolve_longitudinal_func(
138141
"bold": func_q.expect(
139142
func_df, suffix=Suffix.BOLD, desc="preproc", without=["space"]
140143
),
141-
"bold_mask": func_q.find(
144+
"bold_mask": func_q.expect(
142145
func_df, suffix=Suffix.MASK, desc="brain", without=["space"]
143146
),
147+
"regressor_files": {
148+
reg: func_q.expect(
149+
func_df, suffix="regressors", desc=bids_safe_label(reg), extension=".1D"
150+
)
151+
for reg in regressors
152+
},
144153
}
145154

146155

@@ -159,5 +168,18 @@ def export_longitudinal_func(fex: Bids, outputs: FunctionalLongOutputs) -> None:
159168
desc="composite",
160169
extra={"from": "bold", "to": "longitudinal", "mode": "image"},
161170
)
162-
if outputs.bold_mask:
163-
fex.save(outputs.bold_mask, suffix=Suffix.MASK, desc="brain")
171+
fex.save(outputs.bold_mask, suffix=Suffix.MASK, desc="brain")
172+
for reg, path in outputs.regressed_bold.items():
173+
fex.save(
174+
path,
175+
suffix=Suffix.BOLD,
176+
desc="regressed",
177+
extra={"reg": bids_safe_label(reg)},
178+
)
179+
for reg, path in outputs.cleaned_bold.items():
180+
fex.save(
181+
path,
182+
suffix=Suffix.BOLD,
183+
desc="preproc",
184+
extra={"reg": bids_safe_label(reg)},
185+
)

src/rbc/cli/longitudinal.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
from dataclasses import dataclass
6-
from typing import TYPE_CHECKING
6+
from typing import TYPE_CHECKING, Literal
77

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

2728
@classmethod
2829
def validate_namespace(cls, ns: argparse.Namespace) -> LongitudinalArgs:
@@ -38,6 +39,7 @@ def validate_namespace(cls, ns: argparse.Namespace) -> LongitudinalArgs:
3839
registration_template=_or_default(
3940
ns.anat_template, REGISTRATION_TEMPLATES.brain_1mm
4041
),
42+
regressor=ns.regressor,
4143
)
4244

4345

@@ -53,6 +55,7 @@ def main(args: LongitudinalArgs) -> int:
5355
anatomical=args.anatomical,
5456
functional=args.functional,
5557
registration_template=args.registration_template,
58+
regressors=args.regressor,
5659
runner_config=RunnerConfig(
5760
runner=args.runner,
5861
verbose=bool(args.verbose),
@@ -85,6 +88,13 @@ def register_command(
8588
action="store_true",
8689
help="Use functional longitudinal pipeline for processing",
8790
)
91+
parser.add_argument(
92+
"--regressor",
93+
nargs="+",
94+
choices=["36-parameter", "aCompCor"],
95+
default=["36-parameter"],
96+
help="Space-delimited nuisance regression method(s) to apply.",
97+
)
8898

8999
templates = parser.add_argument_group("template overrides")
90100
templates.add_argument(

src/rbc/orchestration/longitudinal.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,15 @@ def process_func(
7575
pipe_ctx: RunContext,
7676
func_df: pl.DataFrame,
7777
tpl_df: pl.DataFrame,
78+
regressors: Sequence[str],
7879
) -> None:
7980
"""Handle functional longitudinal processing for one BOLD run.
8081
8182
Args:
8283
pipe_ctx: RunContext bound to this subject/session.
8384
func_df: Functional derivative DataFrame for this run.
8485
tpl_df: Longitudinal template DataFrame.
86+
regressors: Regressor names (e.g. ``["36-parameter"]``).
8587
"""
8688
row = func_df.filter(suffix=Suffix.BOLD).row(0, named=True)
8789
ents = extract_entities(row, ["task", "run"])
@@ -95,6 +97,7 @@ def process_func(
9597
func_df,
9698
tpl_df,
9799
ses=pipe_ctx.ses, # type: ignore[arg-type]
100+
regressors=regressors,
98101
)
99102
func_outputs = functional_longitudinal(**resolved) # type: ignore[arg-type]
100103
fex = func_q.derive(space="longitudinal")
@@ -109,6 +112,7 @@ def run(
109112
anatomical: bool = True,
110113
functional: bool = True,
111114
registration_template: Path = REGISTRATION_TEMPLATES.brain_1mm,
115+
regressors: Sequence[str] = ("36-parameter",),
112116
runner_config: RunnerConfig | None = None,
113117
) -> None:
114118
"""Run the longitudinal pipeline for all matching subjects/sessions.
@@ -120,6 +124,7 @@ def run(
120124
anatomical: Run anatomical longitudinal processing.
121125
functional: Run functional longitudinal processing.
122126
registration_template: Brain template for ANTs registration.
127+
regressors: Nuisance regressor strategies to apply (e.g. ``["36-parameter"]``).
123128
runner_config: Execution backend configuration.
124129
"""
125130
config = runner_config or RunnerConfig()
@@ -173,7 +178,12 @@ def run(
173178

174179
if functional:
175180
for func_df, _ in iter_session_files(session, groupby=FUNC_GROUP_ENTITIES):
176-
process_func(pipe_ctx=pipe_ctx, func_df=func_df, tpl_df=tpl_df)
181+
process_func(
182+
pipe_ctx=pipe_ctx,
183+
func_df=func_df,
184+
tpl_df=tpl_df,
185+
regressors=regressors,
186+
)
177187

178188
pipe_ctx.ensure_dataset_description()
179189

src/rbc/workflows/functional.py

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ class FunctionalOutputs(NamedTuple):
8181
template_bold: BOLD resampled to template space.
8282
regressed_bold: Nuisance-regressed & non-bandpassed BOLD.
8383
cleaned_bold: Nuisance-regressed & bandpass-filtered BOLD.
84-
regressor_file: Bandpass-filtered nuisance regressor ``.1D`` file.
84+
regressor_file: Nuisance regressor ``.1D`` files.
85+
bpf_regressor_file: Bandpass-filtered nuisance regressor ``.1D`` files.
8586
template_brain_mask: Brain mask warped to template space.
8687
"""
8788

@@ -105,6 +106,7 @@ class FunctionalOutputs(NamedTuple):
105106
regressed_bold: dict[str, Path]
106107
cleaned_bold: dict[str, Path]
107108
regressor_file: dict[str, Path]
109+
bpf_regressor_file: dict[str, Path]
108110
template_brain_mask: Path
109111

110112

@@ -378,7 +380,8 @@ def single_session_preprocess(
378380
template_bold=template_bold,
379381
regressed_bold={r: regression[r].regressed_bold for r in regressor_set},
380382
cleaned_bold={r: cleaned[r].regressed_bold for r in regressor_set},
381-
regressor_file=filtered_regressors,
383+
regressor_file={r: regressors[r].regressor_file for r in regressor_set},
384+
bpf_regressor_file=filtered_regressors,
382385
template_brain_mask=tmpl_brain,
383386
)
384387

@@ -392,12 +395,18 @@ class FunctionalLongOutputs(NamedTuple):
392395
bold: Preprocessed BOLD warped to longitudinal template space.
393396
bold_mask: Brain mask warped to longitudinal template space,
394397
or *None* if no mask was provided.
398+
regressed_bold: Nuisance-regressed (non-bandpassed) BOLD in longitudinal
399+
template space. Suitable for ALFF/fALFF.
400+
cleaned_bold: Nuisance-regressed + bandpass-filtered BOLD in longitudinal
401+
template space (Hallquist 2013).
395402
"""
396403

397404
forward_xfm: Path
398405
sbref: Path
399406
bold: Path
400-
bold_mask: Path | None = None
407+
bold_mask: Path
408+
regressed_bold: dict[str, Path]
409+
cleaned_bold: dict[str, Path]
401410

402411

403412
def longitudinal_process(
@@ -407,41 +416,83 @@ def longitudinal_process(
407416
bold_to_anat_itk: Path,
408417
sbref: Path,
409418
bold: Path,
410-
bold_mask: Path | None,
419+
bold_mask: Path,
420+
regressor_files: dict[str, Path],
411421
) -> FunctionalLongOutputs:
412422
"""Transform preprocessed functional outputs to longitudinal template space.
413423
414424
Assumes a longitudinal template has been generated, the subject-to-template
415425
composite warp is available, and anatomical data has already been processed
416426
to longitudinal template space.
417427
428+
Regressors are computed once during cross-sectional preprocessing and passed
429+
in via ``regressor_files``. Only the regression steps are re-run against the
430+
longitudinal space BOLD.
431+
432+
Steps:
433+
1. Compose BOLD-to-anatomical + anatomical-to-longitudinal-template transforms.
434+
2. Warp sbref (3D) and preproc BOLD (4D) to longitudinal template space.
435+
3. Warp brain mask to longitudinal template space.
436+
4. Nuisance regression without bandpass on longitudinal-space BOLD
437+
(for ALFF/fALFF).
438+
5. Nuisance regression with simultaneous bandpass filtering on longitudinal-space
439+
BOLD (Hallquist 2013).
440+
418441
Args:
419442
template: Longitudinal template image.
420443
anat_to_template_xfm: T1w-to-longitudinal-template composite warp.
421444
bold_to_anat_itk: BOLD-to-T1w affine in ITK format.
422445
sbref: Motion reference (single-band reference) volume.
423446
bold: Preprocessed bold image.
424-
bold_mask: Bold brain mask, if available.
447+
bold_mask: Bold brain mask in native space.
448+
regressor_files: Per-regressor nuisance regressor ``.1D`` files.
449+
425450
426451
Returns:
427452
:class:`FunctionalLongOutputs` with all non-null inputs transformed to template
428453
space.
429454
"""
455+
# 1. Compose full BOLD -> longitudinal template transform
430456
bold_to_tpl_xfm = compose_transform(
431457
ref=template,
432458
bold_to_anat_itk=bold_to_anat_itk,
433459
anat_to_tpl_xfm=anat_to_template_xfm,
434460
)
435461

462+
# 2. Warp sbref & bold to longitudinal space
463+
warped_sbref = func_transform(
464+
in_file=sbref, template=template, xfm=bold_to_tpl_xfm, strategy="single"
465+
)
466+
warped_bold = func_transform(
467+
in_file=bold, template=template, xfm=bold_to_tpl_xfm, strategy="chunked"
468+
)
469+
470+
# 3. Warp bold mask to longitudinal space
471+
warped_mask = mask_transform(mask=bold_mask, template=template, xfm=bold_to_tpl_xfm)
472+
473+
regression: dict[str, ApplyRegressionOutputs] = {}
474+
cleaned: dict[str, ApplyRegressionOutputs] = {}
475+
for reg, reg_file in regressor_files.items():
476+
# 4. Nuisance regression without bandpass
477+
_logger.info("%s nuisance regression (no bandpass)", reg)
478+
regression[reg] = apply_regression(
479+
bold_file=warped_bold,
480+
brain_mask_file=warped_mask,
481+
regressor_file=reg_file,
482+
)
483+
# 5. Simultaneous regression + bandpass filtering (Hallquist 2013)
484+
_logger.info("%s nuisance regression + bandpass filtering", reg)
485+
cleaned[reg] = apply_regression_bandpass(
486+
bold_file=warped_bold,
487+
brain_mask_file=warped_mask,
488+
regressor_file=reg_file,
489+
)
490+
436491
return FunctionalLongOutputs(
437-
sbref=func_transform( # 3D volume
438-
in_file=sbref, template=template, xfm=bold_to_tpl_xfm, strategy="single"
439-
),
440-
bold=func_transform(
441-
in_file=bold, template=template, xfm=bold_to_tpl_xfm, strategy="chunked"
442-
),
443-
bold_mask=mask_transform(mask=bold_mask, template=template, xfm=bold_to_tpl_xfm)
444-
if bold_mask
445-
else None,
446492
forward_xfm=bold_to_tpl_xfm,
493+
sbref=warped_sbref,
494+
bold=warped_bold,
495+
bold_mask=warped_mask,
496+
regressed_bold={r: regression[r].regressed_bold for r in regressor_files},
497+
cleaned_bold={r: cleaned[r].regressed_bold for r in regressor_files},
447498
)

tests/unit/bids/test_exports.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ def _make_func_outputs(w: Path, regressors: list[str]) -> FunctionalOutputs:
7474
regressed_bold={r: _dummy(w, f"regressed_{r}.nii.gz") for r in regressors},
7575
cleaned_bold={r: _dummy(w, f"cleaned_{r}.nii.gz") for r in regressors},
7676
regressor_file={r: _dummy(w, f"regressors_{r}.1D") for r in regressors},
77+
bpf_regressor_file={r: _dummy(w, f"bpf_regressors_{r}.1D") for r in regressors},
7778
template_brain_mask=_dummy(w, "template_mask.nii.gz"),
7879
)
7980

0 commit comments

Comments
 (0)