Skip to content

Commit 8b3ee63

Browse files
nx10jpillai00
andauthored
Metrics, QC & all pipeline in longitudinal space (Stage 6 of #301) (#317)
* Add longitudinal metrics orchestration (port from #297) Resolve per-regressor regressed/cleaned BOLD and brain mask from space-longitudinal derivatives, read TR from NIfTI header, call single_session_metrics unchanged. Reuse cross-sectional export_metrics for BIDS naming. Co-authored-by: Janhavi Pillai <janhavipillai3@gmail.com> * Add longitudinal QC, all pipeline, and full_pipeline tests QC: Dice/Jaccard between anat and BOLD brain masks in longitudinal space, pass threshold Dice >= 0.85. Viz tracked in #303/#304. All pipeline: template -> anat -> func -> metrics -> qc. Template writes to disk cross-session; per-session stages hand off func outputs in-memory. process_anat/process_func now return workflow outputs. CLI: Add --regressor and --task to `rbc longitudinal all`. Tests: Tier-4 full_pipeline tests for metrics, QC, and all-vs-sequential equivalence under tests/full_pipeline/longitudinal/. * Fix anat groupby dropping mask rows in longitudinal resolve T1w suffix filter in run() excluded masks from the DataFrame passed to process_anat, so resolve_longitudinal_anat couldn't find them. Drop the filter. Pre-existing Stage 4 bug exposed by new tests. * Add --regressor to longitudinal metrics CLI Replace DataFrame-based discovery (queried a non-existent 'reg' column) with explicit --regressor, matching cross-sectional. * Fix longitudinal all test fixture missing --session-label Fixture ran cross-sectional func only for ses-test but invoked `rbc longitudinal all` without --session-label, so it iterated ses-retest (no func derivatives) and crashed on an empty BOLD DataFrame. * Apply only participant filter to template discovery in all pipeline Template needs all sessions to find multi-session subjects. Session/task filters apply only to per-session stages, not discover_template_inputs. * Clean up tech debt: TR validation, FS auth, QC load_table TR: Promote resolve_tr/warn_implausible_tr to public API. Replace _read_header_tr with _read_derivative_tr that pipes through resolve_tr for validation and plausibility warnings. Add --tr override to longitudinal metrics CLI. FS auth: Rename _setup_freesurfer_auth -> setup_freesurfer_auth, export from template.py, import at module level in all.py. QC: Replace manual concat of session.anat + session.func + tpl_df with load_table, matching cross-sectional qc.py. --------- Co-authored-by: Janhavi Pillai <janhavi.pillai@gmail.com>
1 parent 5ac4dda commit 8b3ee63

21 files changed

Lines changed: 1363 additions & 102 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""BIDS resolve and export for the longitudinal metrics workflow.
2+
3+
Reuses the cross-sectional :func:`~rbc.bids.metrics.export_metrics` for
4+
export since the output structure (ALFF, fALFF, ReHo, timeseries,
5+
correlations) is identical -- only the space changes to ``longitudinal``.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from typing import TYPE_CHECKING, TypedDict
11+
12+
from rbc.bids import Suffix, bids_safe_label
13+
14+
if TYPE_CHECKING:
15+
from pathlib import Path
16+
17+
import polars as pl
18+
19+
from rbc.bids import Bids
20+
21+
22+
class MetricsLongInputs(TypedDict):
23+
"""Resolved functional inputs for the longitudinal metrics workflow."""
24+
25+
template_brain_mask: Path
26+
regressed_bold: Path
27+
cleaned_bold: Path
28+
29+
30+
def resolve_longitudinal_metrics(
31+
func_long_q: Bids,
32+
func_long_df: pl.DataFrame,
33+
*,
34+
regressor: str,
35+
) -> MetricsLongInputs:
36+
"""Resolve longitudinal-space functional derivatives for metrics.
37+
38+
Args:
39+
func_long_q: Bids builder configured for ``space=longitudinal``
40+
func queries.
41+
func_long_df: DataFrame of longitudinal-space derivative outputs.
42+
regressor: Single regressor name (e.g. ``"36-parameter"``).
43+
44+
Returns:
45+
Dict with keys: ``template_brain_mask``, ``regressed_bold``,
46+
``cleaned_bold``.
47+
"""
48+
return {
49+
"template_brain_mask": func_long_q.expect(
50+
func_long_df, suffix=Suffix.MASK, desc="brain"
51+
),
52+
"regressed_bold": func_long_q.expect(
53+
func_long_df,
54+
suffix=Suffix.BOLD,
55+
desc="regressed",
56+
extra={"reg": bids_safe_label(regressor)},
57+
),
58+
"cleaned_bold": func_long_q.expect(
59+
func_long_df,
60+
suffix=Suffix.BOLD,
61+
desc="preproc",
62+
extra={"reg": bids_safe_label(regressor)},
63+
),
64+
}

src/rbc/bids/longitudinal/qc.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"""BIDS resolve and export for the longitudinal QC workflow.
2+
3+
Longitudinal QC is minimal: Dice/Jaccard overlap between the anatomical
4+
brain mask and BOLD brain mask, both in longitudinal template space.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import csv
10+
from typing import TYPE_CHECKING, NamedTuple, TypedDict
11+
12+
from rbc.bids import Suffix
13+
14+
if TYPE_CHECKING:
15+
from pathlib import Path
16+
17+
import polars as pl
18+
19+
from rbc.bids import Bids
20+
21+
22+
class QCLongInputs(TypedDict):
23+
"""Resolved inputs for the longitudinal registration QC."""
24+
25+
anat_brain_mask: Path
26+
bold_mask: Path
27+
28+
29+
class LongitudinalQCOutputs(NamedTuple):
30+
"""QC outputs from the longitudinal registration overlap check.
31+
32+
Attributes:
33+
dice: Dice coefficient between anat and BOLD masks.
34+
jaccard: Jaccard index between anat and BOLD masks.
35+
coverage: Coverage of the smaller mask by the overlap.
36+
cross_corr: Pearson correlation between flattened masks.
37+
passed: Whether the run passes the Dice threshold.
38+
qc_file: Path to the written single-row QC TSV.
39+
"""
40+
41+
dice: float
42+
jaccard: float
43+
coverage: float
44+
cross_corr: float
45+
passed: bool
46+
qc_file: Path
47+
48+
49+
def resolve_longitudinal_qc(
50+
anat_long_q: Bids,
51+
func_long_q: Bids,
52+
anat_long_df: pl.DataFrame,
53+
func_long_df: pl.DataFrame,
54+
) -> QCLongInputs:
55+
"""Resolve longitudinal-space masks for registration QC.
56+
57+
Args:
58+
anat_long_q: Bids builder for ``space=longitudinal`` anat queries.
59+
func_long_q: Bids builder for ``space=longitudinal`` func queries.
60+
anat_long_df: DataFrame of longitudinal anatomical derivatives.
61+
func_long_df: DataFrame of longitudinal functional derivatives.
62+
63+
Returns:
64+
Dict with ``anat_brain_mask`` and ``bold_mask`` paths.
65+
"""
66+
return {
67+
"anat_brain_mask": anat_long_q.expect(
68+
anat_long_df, suffix=Suffix.MASK, desc="T1w"
69+
),
70+
"bold_mask": func_long_q.expect(func_long_df, suffix=Suffix.MASK, desc="brain"),
71+
}
72+
73+
74+
def export_longitudinal_qc(
75+
func_long: Bids,
76+
outputs: LongitudinalQCOutputs,
77+
) -> None:
78+
"""Export longitudinal QC results as a single-row TSV.
79+
80+
Args:
81+
func_long: Bids builder with ``space="longitudinal"`` for func.
82+
outputs: QC overlap metrics.
83+
"""
84+
func_long.save(
85+
outputs.qc_file,
86+
suffix="quality",
87+
desc="registration",
88+
extension=".tsv",
89+
)
90+
91+
92+
def write_longitudinal_qc_tsv(
93+
out_path: Path,
94+
*,
95+
sub: str,
96+
ses: str,
97+
task: str,
98+
run: int | str,
99+
dice: float,
100+
jaccard: float,
101+
coverage: float,
102+
cross_corr: float,
103+
passed: bool,
104+
) -> Path:
105+
"""Write a single-row longitudinal QC TSV.
106+
107+
Args:
108+
out_path: Destination file path.
109+
sub: Subject ID.
110+
ses: Session label.
111+
task: Task label.
112+
run: Run number.
113+
dice: Dice coefficient.
114+
jaccard: Jaccard index.
115+
coverage: Coverage metric.
116+
cross_corr: Cross-correlation.
117+
passed: Pass/fail flag.
118+
119+
Returns:
120+
The written file path.
121+
"""
122+
fieldnames = [
123+
"sub",
124+
"ses",
125+
"task",
126+
"run",
127+
"dice",
128+
"jaccard",
129+
"coverage",
130+
"cross_corr",
131+
"passed",
132+
]
133+
row = {
134+
"sub": sub,
135+
"ses": ses,
136+
"task": task,
137+
"run": run,
138+
"dice": f"{dice:.6f}",
139+
"jaccard": f"{jaccard:.6f}",
140+
"coverage": f"{coverage:.6f}",
141+
"cross_corr": f"{cross_corr:.6f}",
142+
"passed": str(passed),
143+
}
144+
out_path.parent.mkdir(parents=True, exist_ok=True)
145+
with out_path.open("w", newline="") as fh:
146+
writer = csv.DictWriter(fh, fieldnames=fieldnames, delimiter="\t")
147+
writer.writeheader()
148+
writer.writerow(row)
149+
return out_path

src/rbc/cli/longitudinal/all.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1-
"""``rbc longitudinal all`` subcommand (placeholder for Stage 6)."""
1+
"""``rbc longitudinal all`` subcommand."""
22

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

8-
from rbc.cli.base import _or_default, _validate_nifti_path, _validate_positive
8+
from rbc.cli.base import (
9+
_or_default,
10+
_validate_nifti_path,
11+
_validate_positive,
12+
_validate_task,
13+
)
914
from rbc.cli.longitudinal._base import LongitudinalBaseArgs, add_fs_license_argument
1015
from rbc.cli.metrics import _resolve_atlas_args
1116
from rbc.orchestration import Filters, RunnerConfig
@@ -25,18 +30,23 @@ class AllLongArgs(LongitudinalBaseArgs):
2530
registration_template: Path
2631
atlas_files: dict[str, Path]
2732
fwhm: float
33+
regressor: Sequence[Literal["36-parameter", "aCompCor"]]
34+
task: str | None
2835

2936
@classmethod
3037
def validate_namespace(cls, ns: argparse.Namespace) -> AllLongArgs:
3138
"""Validate namespace for the full longitudinal pipeline subcommand."""
3239
_validate_positive(ns.fwhm, "FWHM")
40+
_validate_task(ns.task)
3341
return cls(
3442
**LongitudinalBaseArgs.validate_namespace(ns).__dict__,
3543
registration_template=_or_default(
3644
ns.anat_template, REGISTRATION_TEMPLATES.brain_1mm
3745
),
3846
atlas_files=_resolve_atlas_args(ns.atlas),
3947
fwhm=ns.fwhm,
48+
regressor=ns.regressor,
49+
task=ns.task,
4050
)
4151

4252

@@ -48,7 +58,9 @@ def main(args: AllLongArgs) -> int:
4858
filters=Filters(
4959
participant_label=args.participant_label,
5060
session_label=args.session_label,
61+
task=args.task,
5162
),
63+
regressors=args.regressor,
5264
fs_license=args.fs_license,
5365
atlas_files=args.atlas_files,
5466
fwhm=args.fwhm,
@@ -66,21 +78,33 @@ def register_command(
6678
subparsers: argparse._SubParsersAction,
6779
parents: Sequence[argparse.ArgumentParser],
6880
) -> None:
69-
"""Register ``rbc longitudinal all`` (Stage 6 placeholder)."""
81+
"""Register ``rbc longitudinal all``."""
7082
parser = subparsers.add_parser(
7183
"all",
7284
parents=parents,
7385
description=(
74-
"Run the full longitudinal pipeline (template anat func "
75-
"metrics qc). Placeholder wired up by Stage 3; full "
76-
"implementation ships in Stage 6."
86+
"Run the full longitudinal pipeline (template -> anat -> func -> "
87+
"metrics -> qc). Passes functional outputs in-memory to metrics "
88+
"and QC stages."
7789
),
78-
help="Full longitudinal pipeline (Stage 6)",
90+
help="Full longitudinal pipeline",
7991
usage=(
8092
"rbc longitudinal all INPUT_DIR [INPUT_DIR ...] -o OUTPUT_DIR [options]"
8193
),
8294
)
8395
add_fs_license_argument(parser)
96+
parser.add_argument(
97+
"--regressor",
98+
nargs="+",
99+
choices=["36-parameter", "aCompCor"],
100+
default=["36-parameter"],
101+
help="Space-delimited nuisance regression method(s) to apply.",
102+
)
103+
parser.add_argument(
104+
"--task",
105+
default=None,
106+
help="Task label to filter BOLD runs (without 'task-' prefix).",
107+
)
84108
parser.add_argument(
85109
"--atlas",
86110
nargs="+",

src/rbc/cli/longitudinal/metrics.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
"""``rbc longitudinal metrics`` subcommand (placeholder for Stage 6)."""
1+
"""``rbc longitudinal metrics`` subcommand."""
22

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 _validate_positive, _validate_task
99
from rbc.cli.longitudinal._base import LongitudinalBaseArgs, add_fs_license_argument
@@ -24,18 +24,23 @@ class MetricsLongArgs(LongitudinalBaseArgs):
2424

2525
atlas_files: dict[str, Path]
2626
fwhm: float
27+
tr: float | None
2728
task: str | None
29+
regressor: Sequence[Literal["36-parameter", "aCompCor"]]
2830

2931
@classmethod
3032
def validate_namespace(cls, ns: argparse.Namespace) -> MetricsLongArgs:
3133
"""Validate namespace for the longitudinal metrics subcommand."""
3234
_validate_task(ns.task)
3335
_validate_positive(ns.fwhm, "FWHM")
36+
_validate_positive(ns.tr, "TR")
3437
return cls(
3538
**LongitudinalBaseArgs.validate_namespace(ns).__dict__,
3639
atlas_files=_resolve_atlas_args(ns.atlas),
3740
fwhm=ns.fwhm,
41+
tr=ns.tr,
3842
task=ns.task,
43+
regressor=ns.regressor,
3944
)
4045

4146

@@ -49,8 +54,10 @@ def main(args: MetricsLongArgs) -> int:
4954
session_label=args.session_label,
5055
task=args.task,
5156
),
57+
regressors=args.regressor,
5258
atlas_files=args.atlas_files,
5359
fwhm=args.fwhm,
60+
tr=args.tr,
5461
runner_config=RunnerConfig(
5562
runner=args.runner,
5663
verbose=bool(args.verbose),
@@ -65,20 +72,27 @@ def register_command(
6572
subparsers: argparse._SubParsersAction,
6673
parents: Sequence[argparse.ArgumentParser],
6774
) -> None:
68-
"""Register ``rbc longitudinal metrics`` (Stage 6 placeholder)."""
75+
"""Register ``rbc longitudinal metrics``."""
6976
parser = subparsers.add_parser(
7077
"metrics",
7178
parents=parents,
7279
description=(
73-
"Compute resting-state metrics in longitudinal space. Placeholder "
74-
"wired up by Stage 3; full implementation ships in Stage 6."
80+
"Compute resting-state metrics (ALFF, fALFF, ReHo, atlas "
81+
"timeseries) on longitudinal-space functional derivatives."
7582
),
76-
help="Longitudinal metrics stage (Stage 6)",
83+
help="Compute resting-state metrics in longitudinal space",
7784
usage=(
7885
"rbc longitudinal metrics INPUT_DIR [INPUT_DIR ...] -o OUTPUT_DIR [options]"
7986
),
8087
)
8188
add_fs_license_argument(parser)
89+
parser.add_argument(
90+
"--regressor",
91+
nargs="+",
92+
choices=["36-parameter", "aCompCor"],
93+
default=["36-parameter"],
94+
help=("Space-delimited nuisance regression method(s) to compute metrics for."),
95+
)
8296
parser.add_argument(
8397
"--atlas",
8498
nargs="+",
@@ -96,6 +110,12 @@ def register_command(
96110
default=6.0,
97111
help="Smoothing kernel FWHM in mm.",
98112
)
113+
parser.add_argument(
114+
"--tr",
115+
type=float,
116+
default=None,
117+
help="Repetition time in seconds. Overrides NIfTI header.",
118+
)
99119
parser.add_argument(
100120
"--task",
101121
default=None,

0 commit comments

Comments
 (0)