Skip to content
Merged
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
9 changes: 8 additions & 1 deletion docs/data_dictionary.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ Produced by `rbc functional`. These are functional MRI (BOLD) processing results
| `*_space-MNI152NLin6Asym_desc-preproc_bold.nii.gz` | `bold` | BOLD timeseries resampled to MNI152NLin6Asym template space in a single interpolation step (before denoising) | ANTs resampling | 4D NIfTI |
| `*_space-MNI152NLin6Asym_desc-bold_mask.nii.gz` | `mask` | Brain mask warped to template space at the BOLD resolution | ANTs resampling | 3D NIfTI, binary mask |
| `*_space-MNI152NLin6Asym_reg-{regressor}_desc-preproc_bold.nii.gz` | `bold` | Denoised BOLD timeseries in template space after nuisance regression and bandpass filtering. `{regressor}` is `36parameter` or `aCompCor` | Nuisance regression | 4D NIfTI |
| `*_desc-{regressor}_regressors.1D` | `regressors` | Nuisance regressor matrix used for denoising. `{regressor}` is `36parameter` or `aCompCor` | Computed from motion parameters and tissue masks | Text, multi-column 1D file |
| `*_desc-{regressor}_regressors.1D` | `regressors` | Raw (unfiltered) nuisance regressor matrix. `{regressor}` is `36parameter` or `aCompCor`. Carried forward for longitudinal regression reuse | Computed from motion parameters and tissue masks | Text, multi-column 1D file |
| `*_desc-{regressor}Filtered_regressors.1D` | `regressors` | Bandpass-filtered nuisance regressor matrix matching what `3dTproject -bandpass` applied. For provenance only | FFT-based bandpass filter | Text, multi-column 1D file |

---

Expand Down Expand Up @@ -133,3 +134,9 @@ Produced by the `rbc longitudinal` subcommand group (`template`, `anatomical`, `
| `*_space-longitudinal_desc-T1w_mask.nii.gz` | `mask` | Brain mask in longitudinal template space | ANTs registration to longitudinal template | 3D NIfTI, binary mask |
| `*_from-T1w_to-longitudinal_mode-image_xfm.nii.gz` | `xfm` | Warp field mapping subject anatomy to the longitudinal template | ANTs registration | 3D NIfTI, displacement field |
| `*_from-longitudinal_to-T1w_mode-image_xfm.nii.gz` | `xfm` | Inverse warp field mapping longitudinal template back to subject anatomy | ANTs registration | 3D NIfTI, displacement field |
| `*_space-longitudinal_sbref.nii.gz` | `sbref` | Motion reference volume warped to longitudinal template space | ANTs warping (composed BOLD-to-longitudinal) | 3D NIfTI |
| `*_space-longitudinal_desc-preproc_bold.nii.gz` | `bold` | Preprocessed BOLD warped to longitudinal template space | ANTs warping (composed BOLD-to-longitudinal) | 4D NIfTI |
| `*_space-longitudinal_desc-brain_mask.nii.gz` | `mask` | BOLD brain mask in longitudinal template space | ANTs warping (nearest-neighbor) | 3D NIfTI, binary mask |
| `*_from-bold_to-longitudinal_...xfm.nii.gz` | `xfm` | Composite BOLD-to-longitudinal-template warp field | ANTs compose transforms | 3D NIfTI, displacement field |
| `*_space-longitudinal_desc-regressed_reg-<strategy>_bold.nii.gz` | `bold` | Nuisance-regressed BOLD (no bandpass) in longitudinal space, per regressor strategy | AFNI 3dTproject | 4D NIfTI |
| `*_space-longitudinal_desc-preproc_reg-<strategy>_bold.nii.gz` | `bold` | Nuisance-regressed + bandpass-filtered BOLD in longitudinal space, per regressor strategy | AFNI 3dTproject -bandpass | 4D NIfTI |
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",
desc=f"{bids_safe_label(reg)}Filtered",
extension=".1D",
)

mni = func.derive(space=TemplateSpace.MNI152NLIN6ASYM)
for reg in regressors:
Expand Down
47 changes: 41 additions & 6 deletions src/rbc/bids/longitudinal/functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from rbc.bids import Suffix, bids_safe_label

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

import polars as pl
Expand All @@ -22,7 +23,8 @@ def resolve_longitudinal_func(
tpl_df: pl.DataFrame,
*,
ses: str,
) -> dict[str, Path | None]:
regressors: Sequence[str] = ("36-parameter",),
) -> dict[str, Path | dict[str, Path]]:
"""Resolve inputs for longitudinal functional processing.

Args:
Expand All @@ -31,10 +33,23 @@ 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 strategy names to resolve raw regressor
files for.

Returns:
Dict with keys matching ``longitudinal_process`` parameters.
Dict with keys matching ``longitudinal_process`` parameters,
including ``regressor_files`` keyed by strategy name.
"""
regressor_files: dict[str, Path] = {}
for reg in regressors:
regressor_files[reg] = func_q.expect(
func_df,
suffix="regressors",
desc=bids_safe_label(reg),
extension=".1D",
without=["space"],
)

return {
"template": tpl_q.expect(tpl_df, suffix="T1w"),
"anat_to_template_xfm": tpl_q.expect(
Expand All @@ -54,18 +69,25 @@ 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": regressor_files,
}


def export_longitudinal_func(fex: Bids, outputs: FunctionalLongOutputs) -> None:
def export_longitudinal_func(
fex: Bids,
outputs: FunctionalLongOutputs,
*,
regressors: Sequence[str],
) -> None:
"""Export longitudinal functional outputs.

Args:
fex: Bids builder with ``space="longitudinal"`` and identity entities.
outputs: Results from the longitudinal functional workflow.
regressors: Regressor strategy names that were applied.
"""
fex.save(outputs.sbref, suffix=Suffix.SBREF)
fex.save(outputs.bold, suffix=Suffix.BOLD, desc="preproc")
Expand All @@ -75,5 +97,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 in regressors:
fex.save(
outputs.regressed_bold[reg],
suffix=Suffix.BOLD,
desc="regressed",
extra={"reg": bids_safe_label(reg)},
)
fex.save(
outputs.cleaned_bold[reg],
suffix=Suffix.BOLD,
desc="preproc",
extra={"reg": bids_safe_label(reg)},
)
14 changes: 12 additions & 2 deletions src/rbc/cli/longitudinal/functional.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 _validate_task
from rbc.cli.longitudinal._base import LongitudinalBaseArgs, add_fs_license_argument
Expand All @@ -20,6 +20,7 @@ class FunctionalLongArgs(LongitudinalBaseArgs):
"""Arguments for ``rbc longitudinal functional``."""

task: str | None
regressor: Sequence[Literal["36-parameter", "aCompCor"]]

@classmethod
def validate_namespace(cls, ns: argparse.Namespace) -> FunctionalLongArgs:
Expand All @@ -28,6 +29,7 @@ def validate_namespace(cls, ns: argparse.Namespace) -> FunctionalLongArgs:
return cls(
**LongitudinalBaseArgs.validate_namespace(ns).__dict__,
task=ns.task,
regressor=ns.regressor,
)


Expand All @@ -41,6 +43,7 @@ def main(args: FunctionalLongArgs) -> int:
session_label=args.session_label,
task=args.task,
),
regressors=args.regressor,
runner_config=RunnerConfig(
runner=args.runner,
verbose=bool(args.verbose),
Expand All @@ -61,7 +64,7 @@ def register_command(
parents=parents,
description=(
"Warp preprocessed BOLD derivatives into each subject's "
"longitudinal template space."
"longitudinal template space and re-run nuisance regression."
),
help="Longitudinal functional stage",
usage=(
Expand All @@ -75,6 +78,13 @@ def register_command(
default=None,
help="Task label to filter BOLD runs (without 'task-' prefix).",
)
parser.add_argument(
"--regressor",
nargs="+",
choices=["36-parameter", "aCompCor"],
default=["36-parameter"],
help="Space-delimited nuisance regression method(s) to apply.",
)

parser.set_defaults(
func=lambda args: main(FunctionalLongArgs.validate_namespace(args))
Expand Down
2 changes: 1 addition & 1 deletion src/rbc/orchestration/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ def apply(self, df: pl.DataFrame, *base_exprs: pl.Expr) -> pl.DataFrame:
if len(self.session_label) > 0:
exprs.append(pl.col("ses").is_in(self.session_label))
if self.task is not None:
exprs.append(pl.col("task") == self.task)
exprs.append(pl.col("task").is_null() | (pl.col("task") == self.task))
if not exprs:
return df
return df.filter(pl.all_horizontal(exprs))
Expand Down
16 changes: 14 additions & 2 deletions src/rbc/orchestration/longitudinal/functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
if TYPE_CHECKING:
from collections.abc import Sequence
from pathlib import Path
from typing import Literal

import polars as pl

Expand All @@ -34,13 +35,16 @@ def process_func(
pipe_ctx: RunContext,
func_df: pl.DataFrame,
tpl_df: pl.DataFrame,
*,
regressors: Sequence[Literal["36-parameter", "aCompCor"]] = ("36-parameter",),
) -> 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 strategies to apply in longitudinal space.
"""
row = func_df.filter(suffix=Suffix.BOLD).row(0, named=True)
ents = extract_entities(row, ["task", "run"])
Expand All @@ -54,17 +58,19 @@ def process_func(
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")
export_longitudinal_func(fex, func_outputs)
export_longitudinal_func(fex, func_outputs, regressors=regressors)


def run(
input_dirs: Sequence[Path],
output_dir: Path,
*,
filters: Filters,
regressors: Sequence[Literal["36-parameter", "aCompCor"]] = ("36-parameter",),
runner_config: RunnerConfig | None = None,
) -> None:
"""Run longitudinal functional processing for all matching subjects/sessions.
Expand All @@ -75,6 +81,7 @@ def run(
and longitudinal templates).
output_dir: Output directory for derivatives.
filters: Participant/session/task filters applied before grouping.
regressors: Regressor strategies to apply in longitudinal space.
runner_config: Execution backend configuration.
"""
config = runner_config or RunnerConfig()
Expand All @@ -91,7 +98,12 @@ def run(
input_dirs, output_dir, filters=filters, verbose=verbose
):
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()

_logger.info("RBC longitudinal functional workflow complete")
19 changes: 15 additions & 4 deletions src/rbc/workflows/functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,12 @@ 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: Raw (unfiltered) nuisance regressor ``.1D`` file,
as computed from native-space BOLD. Carried forward so
longitudinal regression can reuse it without recomputation.
bpf_regressor_file: Bandpass-filtered nuisance regressor ``.1D``
file, matching what ``3dTproject -bandpass`` actually applied.
For BIDS export only.
template_brain_mask: Brain mask warped to template space.
"""

Expand All @@ -100,6 +105,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 @@ -329,6 +335,7 @@ def single_session_preprocess(

regression: dict[str, ApplyRegressionOutputs] = {}
cleaned: dict[str, ApplyRegressionOutputs] = {}
raw_regressors: dict[str, Path] = {}
filtered_regressors: dict[str, Path] = {}
for regressor in regressor_set:
# 15. Nuisance regression without bandpass (pre-bandpass residuals
Expand All @@ -350,8 +357,11 @@ def single_session_preprocess(
regressor_file=regressors[regressor].regressor_file,
)

# 17. Export bandpass-filtered regressors (matches what 3dTproject
# actually applied; raw regressors still in compute_regressors output)
# 17a. Carry raw (unfiltered) regressors forward for longitudinal reuse
raw_regressors[regressor] = regressors[regressor].regressor_file

# 17b. Export bandpass-filtered regressors (matches what 3dTproject
# actually applied)
filtered_regressors[regressor] = bandpass_regressor_file(
regressors[regressor].regressor_file,
tr=metadata.tr,
Expand Down Expand Up @@ -379,6 +389,7 @@ 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=raw_regressors,
bpf_regressor_file=filtered_regressors,
template_brain_mask=tmpl_brain,
)
Loading
Loading