From b8e58cc89b055b768b2a57c704b4e25f696b38fb Mon Sep 17 00:00:00 2001 From: Janhavi Pillai Date: Mon, 13 Apr 2026 15:56:03 -0700 Subject: [PATCH 01/13] add smoothing to metrics --- src/rbc/bids/metrics.py | 33 ++++++++++++++---- src/rbc/workflows/metrics.py | 67 +++++++++++++++++++++++------------- 2 files changed, 70 insertions(+), 30 deletions(-) diff --git a/src/rbc/bids/metrics.py b/src/rbc/bids/metrics.py index 422a9918..274f371d 100644 --- a/src/rbc/bids/metrics.py +++ b/src/rbc/bids/metrics.py @@ -64,26 +64,47 @@ def export_metrics( *, regressor: str, atlases: Sequence[str], + fwhm: float, ) -> None: """Export metrics for a single regressor to BIDS-named derivatives. + Raw and z-scored raw maps are always exported. Smoothed and + z-scored smoothed variants are exported only when the corresponding + fields are not None (i.e. when ``smooth=True`` was passed to + ``single_session_metrics``). + Args: mni: MNI-space Bids builder (typically from :func:`~rbc.bids.functional.export_functional`). outputs: Results from the metrics workflow. regressor: The regressor this run used. atlases: Atlas names used for timeseries extraction. + fwhm: Smoothing kernel FWHM in mm. """ mex = mni.derive(extra={"reg": bids_safe_label(regressor)}) + sm_desc = f"sm{int(fwhm)}" + + # Raw maps mex.save(outputs.alff, suffix="alff") mex.save(outputs.falff, suffix="falff") - mex.save(outputs.alff_smooth, suffix="alff", desc="smooth") - mex.save(outputs.falff_smooth, suffix="falff", desc="smooth") - mex.save(outputs.alff_zscored, suffix="alff", desc="smoothZstd") - mex.save(outputs.falff_zscored, suffix="falff", desc="smoothZstd") mex.save(outputs.reho, suffix="reho") - mex.save(outputs.reho_smooth, suffix="reho", desc="smooth") - mex.save(outputs.reho_zscored, suffix="reho", desc="smoothZstd") + + # Z-scored raw maps + mex.save(outputs.alff_zscored, suffix="alff", desc="zstd") + mex.save(outputs.falff_zscored, suffix="falff", desc="zstd") + mex.save(outputs.reho_zscored, suffix="reho", desc="zstd") + + # Smoothed + z-scored smoothed -> only when smooth=True + if outputs.alff_smooth is not None: + mex.save(outputs.alff_smooth, suffix="alff", desc=sm_desc) + mex.save(outputs.alff_smooth_zscored, suffix="alff", desc=f"{sm_desc}Zstd") + if outputs.falff_smooth is not None: + mex.save(outputs.falff_smooth, suffix="falff", desc=sm_desc) + mex.save(outputs.falff_smooth_zscored, suffix="falff", desc=f"{sm_desc}Zstd") + if outputs.reho_smooth is not None: + mex.save(outputs.reho_smooth, suffix="reho", desc=sm_desc) + mex.save(outputs.reho_smooth_zscored, suffix="reho", desc=f"{sm_desc}Zstd") + for atl in atlases: mex.save( outputs.timeseries[atl], diff --git a/src/rbc/workflows/metrics.py b/src/rbc/workflows/metrics.py index 929f2d9d..a40b6a32 100644 --- a/src/rbc/workflows/metrics.py +++ b/src/rbc/workflows/metrics.py @@ -32,26 +32,32 @@ class MetricsOutputs(NamedTuple): Attributes: alff: Raw ALFF map. falff: Raw fALFF map. - alff_smooth: Smoothed ALFF map. - falff_smooth: Smoothed fALFF map. - alff_zscored: Z-scored (smoothed) ALFF map. - falff_zscored: Z-scored (smoothed) fALFF map. reho: Raw ReHo map. - reho_smooth: Smoothed ReHo map. - reho_zscored: Z-scored (smoothed) ReHo map. + alff_zscored: Z-scored raw ALFF map. + falff_zscored: Z-scored raw fALFF map. + reho_zscored: Z-scored raw ReHo map. + alff_smooth: Smoothed ALFF map, or None if --smooth not set. + falff_smooth: Smoothed fALFF map, or None if --smooth not set. + reho_smooth: Smoothed ReHo map, or None if --smooth not set. + alff_smooth_zscored: Z-scored smoothed ALFF map, or None if --smooth not set. + falff_smooth_zscored: Z-scored smoothed fALFF map, or None if --smooth not set. + reho_smooth_zscored: Z-scored smoothed ReHo map, or None if --smooth not set. timeseries: Atlas-based mean timeseries TSV. correlation_matrix: Pairwise correlation matrix TSV. """ alff: Path falff: Path - alff_smooth: Path - falff_smooth: Path + reho: Path alff_zscored: Path falff_zscored: Path - reho: Path - reho_smooth: Path reho_zscored: Path + alff_smooth: Path | None + falff_smooth: Path | None + reho_smooth: Path | None + alff_smooth_zscored: Path | None + falff_smooth_zscored: Path | None + reho_smooth_zscored: Path | None timeseries: dict[str, Path] correlation_matrix: dict[str, Path] @@ -63,6 +69,7 @@ def single_session_metrics( tr: float, atlas_files: Mapping[str, Path], fwhm: float = 6.0, + apply_smooth: bool = False, ) -> MetricsOutputs: """Compute all derivative metrics for a single functional run. @@ -95,17 +102,26 @@ def single_session_metrics( cleaned_bold, template_brain_mask, out_file=work_dir / "reho.nii.gz" ) - # 3. Smooth raw maps - _logger.info("Smoothing maps (FWHM=%.1f mm)", fwhm) - alff_smooth_path = smooth(alff_path, template_brain_mask, fwhm=fwhm) - falff_smooth_path = smooth(falff_path, template_brain_mask, fwhm=fwhm) - reho_smooth_path = smooth(reho_path, template_brain_mask, fwhm=fwhm) + # 3. Z-score raw maps (always) + _logger.info("Z-scoring raw maps") + alff_zscored_path = compute_zscore(alff_path, template_brain_mask) + falff_zscored_path = compute_zscore(falff_path, template_brain_mask) + reho_zscored_path = compute_zscore(reho_path, template_brain_mask) + + # 4. Smooth + z-score smoothed maps (optional) + alff_smooth_path = falff_smooth_path = reho_smooth_path = None + alff_smooth_zscored_path = falff_smooth_zscored_path = reho_smooth_zscored_path = None - # 4. Z-score smoothed maps - _logger.info("Z-scoring smoothed maps") - alff_zscored_path = compute_zscore(alff_smooth_path, template_brain_mask) - falff_zscored_path = compute_zscore(falff_smooth_path, template_brain_mask) - reho_zscored_path = compute_zscore(reho_smooth_path, template_brain_mask) + if apply_smooth: + _logger.info("Smoothing derivative maps (FWHM=%.1f mm)", fwhm) + alff_smooth_path = smooth(alff_path, template_brain_mask, fwhm=fwhm) + falff_smooth_path = smooth(falff_path, template_brain_mask, fwhm=fwhm) + reho_smooth_path = smooth(reho_path, template_brain_mask, fwhm=fwhm) + + _logger.info("Z-scoring smoothed maps") + alff_smooth_zscored_path = compute_zscore(alff_smooth_path, template_brain_mask) + falff_smooth_zscored_path = compute_zscore(falff_smooth_path, template_brain_mask) + reho_smooth_zscored_path = compute_zscore(reho_smooth_path, template_brain_mask) # 5. Atlas timeseries + correlation matrix from nuisance-regressed, # bandpass-filtered BOLD @@ -119,13 +135,16 @@ def single_session_metrics( return MetricsOutputs( alff=alff_path, falff=falff_path, - alff_smooth=alff_smooth_path, - falff_smooth=falff_smooth_path, + reho=reho_path, alff_zscored=alff_zscored_path, falff_zscored=falff_zscored_path, - reho=reho_path, - reho_smooth=reho_smooth_path, reho_zscored=reho_zscored_path, + alff_smooth=alff_smooth_path, + falff_smooth=falff_smooth_path, + reho_smooth=reho_smooth_path, + alff_smooth_zscored=alff_smooth_zscored_path, + falff_smooth_zscored=falff_smooth_zscored_path, + reho_smooth_zscored=reho_smooth_zscored_path, timeseries={label: ts.timeseries for label, ts in ts_outputs.items()}, correlation_matrix={ label: ts.correlation_matrix for label, ts in ts_outputs.items() From 303cf6fd22588b7e62398a0d8616745456faf84a Mon Sep 17 00:00:00 2001 From: Janhavi Pillai Date: Tue, 14 Apr 2026 16:10:22 -0400 Subject: [PATCH 02/13] mypy & ruff --- src/rbc/bids/metrics.py | 3 +++ src/rbc/orchestration/all.py | 1 + src/rbc/orchestration/metrics.py | 5 +++- src/rbc/workflows/metrics.py | 11 +++++++-- tests/full_pipeline/test_metrics.py | 2 ++ tests/unit/bids/test_exports.py | 37 ++++++++++++++++++----------- 6 files changed, 42 insertions(+), 17 deletions(-) diff --git a/src/rbc/bids/metrics.py b/src/rbc/bids/metrics.py index 274f371d..cf1a26cb 100644 --- a/src/rbc/bids/metrics.py +++ b/src/rbc/bids/metrics.py @@ -97,12 +97,15 @@ def export_metrics( # Smoothed + z-scored smoothed -> only when smooth=True if outputs.alff_smooth is not None: mex.save(outputs.alff_smooth, suffix="alff", desc=sm_desc) + assert outputs.alff_smooth_zscored is not None # noqa: S101 mex.save(outputs.alff_smooth_zscored, suffix="alff", desc=f"{sm_desc}Zstd") if outputs.falff_smooth is not None: mex.save(outputs.falff_smooth, suffix="falff", desc=sm_desc) + assert outputs.falff_smooth_zscored is not None # noqa: S101 mex.save(outputs.falff_smooth_zscored, suffix="falff", desc=f"{sm_desc}Zstd") if outputs.reho_smooth is not None: mex.save(outputs.reho_smooth, suffix="reho", desc=sm_desc) + assert outputs.reho_smooth_zscored is not None # noqa: S101 mex.save(outputs.reho_smooth_zscored, suffix="reho", desc=f"{sm_desc}Zstd") for atl in atlases: diff --git a/src/rbc/orchestration/all.py b/src/rbc/orchestration/all.py index 2a5454fa..c6136f90 100644 --- a/src/rbc/orchestration/all.py +++ b/src/rbc/orchestration/all.py @@ -147,6 +147,7 @@ def run( metrics_outputs, regressor=regressor, atlases=list(atlas_files), + fwhm=fwhm, ) # QC diff --git a/src/rbc/orchestration/metrics.py b/src/rbc/orchestration/metrics.py index a4454a7a..c2042d07 100644 --- a/src/rbc/orchestration/metrics.py +++ b/src/rbc/orchestration/metrics.py @@ -72,7 +72,9 @@ def process_run( atlas_files=atlas_files, fwhm=fwhm, ) - export_metrics(mni, outputs, regressor=regressor, atlases=list(atlas_files)) + export_metrics( + mni, outputs, regressor=regressor, atlases=list(atlas_files), fwhm=fwhm + ) return outputs @@ -147,6 +149,7 @@ def run( outputs, regressor=regressor, atlases=list(atlas_files), + fwhm=fwhm, ) pipe_ctx.ensure_dataset_description() diff --git a/src/rbc/workflows/metrics.py b/src/rbc/workflows/metrics.py index a40b6a32..594fb2f6 100644 --- a/src/rbc/workflows/metrics.py +++ b/src/rbc/workflows/metrics.py @@ -69,6 +69,7 @@ def single_session_metrics( tr: float, atlas_files: Mapping[str, Path], fwhm: float = 6.0, + *, apply_smooth: bool = False, ) -> MetricsOutputs: """Compute all derivative metrics for a single functional run. @@ -80,6 +81,8 @@ def single_session_metrics( tr: Repetition time in seconds. atlas_files: Mapping of atlas labels to resolved NIfTI file paths. fwhm: Smoothing kernel FWHM in mm. + apply_smooth: If True, produce smoothed and z-scored variants of all + derivative maps in addition to the raw maps. Returns: All metric outputs bundled in a :class:`MetricsOutputs` tuple. @@ -110,7 +113,9 @@ def single_session_metrics( # 4. Smooth + z-score smoothed maps (optional) alff_smooth_path = falff_smooth_path = reho_smooth_path = None - alff_smooth_zscored_path = falff_smooth_zscored_path = reho_smooth_zscored_path = None + alff_smooth_zscored_path = falff_smooth_zscored_path = reho_smooth_zscored_path = ( + None + ) if apply_smooth: _logger.info("Smoothing derivative maps (FWHM=%.1f mm)", fwhm) @@ -120,7 +125,9 @@ def single_session_metrics( _logger.info("Z-scoring smoothed maps") alff_smooth_zscored_path = compute_zscore(alff_smooth_path, template_brain_mask) - falff_smooth_zscored_path = compute_zscore(falff_smooth_path, template_brain_mask) + falff_smooth_zscored_path = compute_zscore( + falff_smooth_path, template_brain_mask + ) reho_smooth_zscored_path = compute_zscore(reho_smooth_path, template_brain_mask) # 5. Atlas timeseries + correlation matrix from nuisance-regressed, diff --git a/tests/full_pipeline/test_metrics.py b/tests/full_pipeline/test_metrics.py index 8b08812a..017e5411 100644 --- a/tests/full_pipeline/test_metrics.py +++ b/tests/full_pipeline/test_metrics.py @@ -41,6 +41,8 @@ def test_single_session_metrics( for output in result: paths = output.values() if isinstance(output, dict) else [output] for path in paths: + if path is None: + continue assert Path(path).exists() manifest["metrics"] = _to_dict(result) diff --git a/tests/unit/bids/test_exports.py b/tests/unit/bids/test_exports.py index 33ec4852..35ce0f80 100644 --- a/tests/unit/bids/test_exports.py +++ b/tests/unit/bids/test_exports.py @@ -83,13 +83,16 @@ def _make_metrics_outputs(w: Path, atlases: list[str]) -> MetricsOutputs: return MetricsOutputs( alff=_dummy(w, "alff.nii.gz"), falff=_dummy(w, "falff.nii.gz"), - alff_smooth=_dummy(w, "alff_smooth.nii.gz"), - falff_smooth=_dummy(w, "falff_smooth.nii.gz"), - alff_zscored=_dummy(w, "alff_z.nii.gz"), - falff_zscored=_dummy(w, "falff_z.nii.gz"), reho=_dummy(w, "reho.nii.gz"), - reho_smooth=_dummy(w, "reho_smooth.nii.gz"), - reho_zscored=_dummy(w, "reho_z.nii.gz"), + alff_zscored=_dummy(w, "alff_zstd.nii.gz"), + falff_zscored=_dummy(w, "falff_zstd.nii.gz"), + reho_zscored=_dummy(w, "reho_zstd.nii.gz"), + alff_smooth=None, + falff_smooth=None, + reho_smooth=None, + alff_smooth_zscored=None, + falff_smooth_zscored=None, + reho_smooth_zscored=None, timeseries={a: _dummy(w, f"ts_{a}.tsv") for a in atlases}, correlation_matrix={a: _dummy(w, f"corr_{a}.tsv") for a in atlases}, ) @@ -233,7 +236,9 @@ def test_sanitizes_atlas_labels( """Atlas names with underscores are sanitized in filenames.""" mni = func_bids.derive(space="MNI152NLin6Asym") outputs = _make_metrics_outputs(workdir, ["schaefer_200"]) - export_metrics(mni, outputs, regressor="36-parameter", atlases=["schaefer_200"]) + export_metrics( + mni, outputs, regressor="36-parameter", atlases=["schaefer_200"], fwhm=6.0 + ) atlas_files = [ p.name for p in pipe_ctx.output_dir.rglob("*.*") if "atlas-" in p.name ] @@ -248,7 +253,9 @@ def test_sanitizes_regressor_labels( """Regressor names with hyphens are sanitized in filenames.""" mni = func_bids.derive(space="MNI152NLin6Asym") outputs = _make_metrics_outputs(workdir, ["aal"]) - export_metrics(mni, outputs, regressor="36-parameter", atlases=["aal"]) + export_metrics( + mni, outputs, regressor="36-parameter", atlases=["aal"], fwhm=6.0 + ) all_names = [p.name for p in pipe_ctx.output_dir.rglob("*.*")] reg_files = [n for n in all_names if "reg-" in n] assert len(reg_files) > 0 @@ -258,23 +265,25 @@ def test_sanitizes_regressor_labels( def test_file_count_single_atlas( self, func_bids: Bids, workdir: Path, pipe_ctx: RunContext ) -> None: - """9 scalar maps + 2 atlas files = 11.""" + """3 raw + 3 zscored + 2 atlas files = 8.""" mni = func_bids.derive(space="MNI152NLin6Asym") outputs = _make_metrics_outputs(workdir, ["schaefer_200"]) - export_metrics(mni, outputs, regressor="aCompCor", atlases=["schaefer_200"]) + export_metrics( + mni, outputs, regressor="aCompCor", atlases=["schaefer_200"], fwhm=6.0 + ) saved = list(pipe_ctx.output_dir.rglob("*.*")) - assert len(saved) == 11 + assert len(saved) == 8 def test_file_count_multiple_atlases( self, func_bids: Bids, workdir: Path, pipe_ctx: RunContext ) -> None: - """9 scalar maps + 2 * 2 atlas files = 13.""" + """3 raw + 3 zscored + 4 atlas files = 10.""" atlases = ["schaefer_200", "aal"] mni = func_bids.derive(space="MNI152NLin6Asym") outputs = _make_metrics_outputs(workdir, atlases) - export_metrics(mni, outputs, regressor="aCompCor", atlases=atlases) + export_metrics(mni, outputs, regressor="aCompCor", atlases=atlases, fwhm=6.0) saved = list(pipe_ctx.output_dir.rglob("*.*")) - assert len(saved) == 13 + assert len(saved) == 10 # --------------------------------------------------------------------------- From 2c9bb495aca40615e6b9fae4d5ad4d0a74c84b2f Mon Sep 17 00:00:00 2001 From: Janhavi Pillai Date: Thu, 16 Apr 2026 11:31:32 -0400 Subject: [PATCH 03/13] update metrics to one flag and add functional smoothing --- src/rbc/bids/functional.py | 10 ++++++++ src/rbc/bids/metrics.py | 36 ++++++++++++++++------------- src/rbc/cli/all.py | 14 +++++------ src/rbc/cli/functional.py | 12 ++++++++++ src/rbc/cli/metrics.py | 15 ++++++------ src/rbc/orchestration/functional.py | 8 ++++++- src/rbc/orchestration/metrics.py | 16 ++++++------- src/rbc/workflows/functional.py | 31 +++++++++++++++++++++++++ src/rbc/workflows/metrics.py | 21 ++++++++--------- 9 files changed, 113 insertions(+), 50 deletions(-) diff --git a/src/rbc/bids/functional.py b/src/rbc/bids/functional.py index 17ece5b2..071fe3aa 100644 --- a/src/rbc/bids/functional.py +++ b/src/rbc/bids/functional.py @@ -104,6 +104,7 @@ def export_functional( outputs: FunctionalOutputs, *, regressors: Sequence[str], + smooth: float | None = None, ) -> Bids: """Export functional workflow outputs to BIDS-named derivatives. @@ -111,6 +112,8 @@ def export_functional( func: Bids builder with ``datatype=FUNC`` and identity entities. outputs: Results from the functional preprocessing workflow. regressors: Regressor names (e.g. ``["36-parameter"]``). + smooth: Smoothing kernel FWHM in mm, or ``None`` if smoothing + was not requested. Returns: The MNI-space Bids builder, for use by downstream exports @@ -179,6 +182,13 @@ def export_functional( desc="preproc", extra={"reg": bids_safe_label(reg)}, ) + if outputs.cleaned_bold_smooth is not None and smooth is not None: + mni.save( + outputs.cleaned_bold_smooth[reg], + suffix=Suffix.BOLD, + desc=f"sm{int(smooth)}preproc", + extra={"reg": bids_safe_label(reg)}, + ) mni.save(outputs.template_bold, suffix=Suffix.BOLD, desc="preproc") mni.save(outputs.template_brain_mask, suffix=Suffix.MASK, desc="bold") diff --git a/src/rbc/bids/metrics.py b/src/rbc/bids/metrics.py index cf1a26cb..52695c61 100644 --- a/src/rbc/bids/metrics.py +++ b/src/rbc/bids/metrics.py @@ -64,7 +64,7 @@ def export_metrics( *, regressor: str, atlases: Sequence[str], - fwhm: float, + smooth: float | None, ) -> None: """Export metrics for a single regressor to BIDS-named derivatives. @@ -79,10 +79,10 @@ def export_metrics( outputs: Results from the metrics workflow. regressor: The regressor this run used. atlases: Atlas names used for timeseries extraction. - fwhm: Smoothing kernel FWHM in mm. + smooth: Smoothing kernel FWHM in mm, or ``None`` if smoothing + was not requested. """ mex = mni.derive(extra={"reg": bids_safe_label(regressor)}) - sm_desc = f"sm{int(fwhm)}" # Raw maps mex.save(outputs.alff, suffix="alff") @@ -94,19 +94,23 @@ def export_metrics( mex.save(outputs.falff_zscored, suffix="falff", desc="zstd") mex.save(outputs.reho_zscored, suffix="reho", desc="zstd") - # Smoothed + z-scored smoothed -> only when smooth=True - if outputs.alff_smooth is not None: - mex.save(outputs.alff_smooth, suffix="alff", desc=sm_desc) - assert outputs.alff_smooth_zscored is not None # noqa: S101 - mex.save(outputs.alff_smooth_zscored, suffix="alff", desc=f"{sm_desc}Zstd") - if outputs.falff_smooth is not None: - mex.save(outputs.falff_smooth, suffix="falff", desc=sm_desc) - assert outputs.falff_smooth_zscored is not None # noqa: S101 - mex.save(outputs.falff_smooth_zscored, suffix="falff", desc=f"{sm_desc}Zstd") - if outputs.reho_smooth is not None: - mex.save(outputs.reho_smooth, suffix="reho", desc=sm_desc) - assert outputs.reho_smooth_zscored is not None # noqa: S101 - mex.save(outputs.reho_smooth_zscored, suffix="reho", desc=f"{sm_desc}Zstd") + # Smoothed + z-scored smoothed + if smooth is not None: + sm_desc = f"sm{int(smooth)}" + if outputs.alff_smooth is not None: + mex.save(outputs.alff_smooth, suffix="alff", desc=sm_desc) + assert outputs.alff_smooth_zscored is not None # noqa: S101 + mex.save(outputs.alff_smooth_zscored, suffix="alff", desc=f"{sm_desc}Zstd") + if outputs.falff_smooth is not None: + mex.save(outputs.falff_smooth, suffix="falff", desc=sm_desc) + assert outputs.falff_smooth_zscored is not None # noqa: S101 + mex.save( + outputs.falff_smooth_zscored, suffix="falff", desc=f"{sm_desc}Zstd" + ) + if outputs.reho_smooth is not None: + mex.save(outputs.reho_smooth, suffix="reho", desc=sm_desc) + assert outputs.reho_smooth_zscored is not None # noqa: S101 + mex.save(outputs.reho_smooth_zscored, suffix="reho", desc=f"{sm_desc}Zstd") for atl in atlases: mex.save( diff --git a/src/rbc/cli/all.py b/src/rbc/cli/all.py index 313f5668..0b5d5080 100644 --- a/src/rbc/cli/all.py +++ b/src/rbc/cli/all.py @@ -38,7 +38,7 @@ class AllArgs(BaseArgs): regressor: Sequence[Literal["36-parameter", "aCompCor"]] task: str | None atlas_files: dict[str, Path] - fwhm: float + smooth: float | None start_tr: int tr: float | None brain_extraction_templates: BrainExtractionTemplates @@ -51,7 +51,6 @@ class AllArgs(BaseArgs): def validate_namespace(cls, ns: argparse.Namespace) -> AllArgs: """Validate all-workflow arguments.""" _validate_task(ns.task) - _validate_positive(ns.fwhm, "FWHM") _validate_positive(ns.start_tr, "Start TR") _validate_positive(ns.tr, "TR") atlas_files = _resolve_atlas_args(ns.atlas) @@ -60,7 +59,7 @@ def validate_namespace(cls, ns: argparse.Namespace) -> AllArgs: regressor=ns.regressor, task=ns.task, atlas_files=atlas_files, - fwhm=ns.fwhm, + smooth=ns.smooth, start_tr=ns.start_tr, tr=ns.tr, brain_extraction_templates=_build_brain_extraction_templates(ns), @@ -91,7 +90,7 @@ def main(args: AllArgs) -> int: ), regressors=args.regressor, atlas_files=args.atlas_files, - fwhm=args.fwhm, + smooth=args.smooth, start_tr=args.start_tr, tr=args.tr, brain_extraction_templates=args.brain_extraction_templates, @@ -144,10 +143,11 @@ def register_command( ), ) parser.add_argument( - "--fwhm", + "--smooth", type=float, - default=6.0, - help="Smoothing kernel FWHM in mm.", + default=None, + help="Smoothing with the kernel of specified FWHM in mm (e.g. --smooth 6.0) " + "If omitted, no smoothing is applied.", ) parser.add_argument( "--start-tr", diff --git a/src/rbc/cli/functional.py b/src/rbc/cli/functional.py index f4dc823d..174b3ed6 100644 --- a/src/rbc/cli/functional.py +++ b/src/rbc/cli/functional.py @@ -36,6 +36,7 @@ class FunctionalArgs(BaseArgs): regressor: Sequence[Literal["36-parameter", "aCompCor"]] task: str | None tr: float | None + smooth: float | None func_template: Path func_template_mask: Path func_template_ref: Path @@ -50,6 +51,7 @@ def validate_namespace(cls, ns: argparse.Namespace) -> FunctionalArgs: regressor=ns.regressor, task=ns.task, tr=ns.tr, + smooth=ns.smooth, func_template=_or_default( ns.func_template, REGISTRATION_TEMPLATES.brain_2mm ), @@ -74,6 +76,7 @@ def main(args: FunctionalArgs) -> int: ), regressors=args.regressor, tr=args.tr, + smooth=args.smooth, func_template=args.func_template, func_template_mask=args.func_template_mask, func_template_ref=args.func_template_ref, @@ -116,6 +119,15 @@ def register_command( default=None, help="Repetition time in seconds. Overrides BIDS sidecar and NIfTI header.", ) + parser.add_argument( + "--smooth", + type=float, + default=None, + metavar="FWHM", + help="Smooth the cleaned (post-regression, bandpass-filtered) BOLD with " + "the kernel of specified FWHM in mm (e.g. --smooth 6.0) " + "If omitted, no smoothing is applied.", + ) templates = parser.add_argument_group("template overrides") templates.add_argument( diff --git a/src/rbc/cli/metrics.py b/src/rbc/cli/metrics.py index fab94bc7..507ebc05 100644 --- a/src/rbc/cli/metrics.py +++ b/src/rbc/cli/metrics.py @@ -50,7 +50,7 @@ class MetricsArgs(BaseArgs): """Arguments for the metrics CLI.""" atlas_files: dict[str, Path] - fwhm: float + smooth: float | None task: str | None regressor: Sequence[Literal["36-parameter", "aCompCor"]] tr: float | None @@ -59,13 +59,12 @@ class MetricsArgs(BaseArgs): def validate_namespace(cls, ns: argparse.Namespace) -> MetricsArgs: """Validate metrics-specific arguments.""" _validate_task(ns.task) - _validate_positive(ns.fwhm, "FWHM") _validate_positive(ns.tr, "TR") atlas_files = _resolve_atlas_args(ns.atlas) return cls( **BaseArgs.validate_namespace(ns).__dict__, atlas_files=atlas_files, - fwhm=ns.fwhm, + smooth=ns.smooth, task=ns.task, regressor=ns.regressor, tr=ns.tr, @@ -83,7 +82,7 @@ def main(args: MetricsArgs) -> int: ), regressors=args.regressor, atlas_files=args.atlas_files, - fwhm=args.fwhm, + smooth=args.smooth, tr=args.tr, runner_config=RunnerConfig( runner=args.runner, @@ -118,10 +117,12 @@ def register_command( ), ) parser.add_argument( - "--fwhm", + "--smooth", type=float, - default=6.0, - help="Smoothing kernel FWHM in mm.", + default=None, + metavar="FWHM", + help="Smoothing with the kernel of specified FWHM in mm (e.g. --smooth 6.0) " + "If omitted, no smoothing is applied.", ) parser.add_argument( "--task", diff --git a/src/rbc/orchestration/functional.py b/src/rbc/orchestration/functional.py index d6058814..560f4842 100644 --- a/src/rbc/orchestration/functional.py +++ b/src/rbc/orchestration/functional.py @@ -40,6 +40,7 @@ def process_session( regressors: Sequence[str], anat_inputs: FunctionalInputs | None = None, tr: float | None = None, + smooth: float | None = None, func_template: Path = REGISTRATION_TEMPLATES.brain_2mm, func_template_mask: Path = REGISTRATION_TEMPLATES.brain_mask_2mm, func_template_ref: Path = REGISTRATION_TEMPLATES.bold_ref, @@ -54,6 +55,7 @@ def process_session( the combined ``all`` pipeline), skips the DataFrame-based resolve and uses these paths directly for every BOLD run. tr: TR override in seconds, or ``None`` to read from headers. + smooth: Smoothing kernel FWHM in mm, or ``None`` to skip smoothing. func_template: Brain template for functional resampling (default: MNI152 2 mm). func_template_mask: Brain mask for functional masking (default: MNI152 2 mm). func_template_ref: BOLD reference image for functional masking. @@ -83,6 +85,7 @@ def process_session( wm_mask=resolved["wm_mask"], anat_to_template=resolved["anat_to_template"], metadata=func_metadata, + smooth=smooth, regressor_set=regressors, # type: ignore[arg-type] func_template=func_template, func_template_mask=func_template_mask, @@ -90,7 +93,7 @@ def process_session( ) func = pipe_ctx.bids(datatype=Datatype.FUNC, entities=func_run.entities) - mni = export_functional(func, outputs, regressors=regressors) + mni = export_functional(func, outputs, regressors=regressors, smooth=smooth) results.append((outputs, mni, func_metadata)) return results @@ -103,6 +106,7 @@ def run( filters: Filters, regressors: Sequence[str], tr: float | None = None, + smooth: float | None = None, func_template: Path = REGISTRATION_TEMPLATES.brain_2mm, func_template_mask: Path = REGISTRATION_TEMPLATES.brain_mask_2mm, func_template_ref: Path = REGISTRATION_TEMPLATES.bold_ref, @@ -116,6 +120,7 @@ def run( filters: Participant/session/task filters. regressors: Regressor names. tr: TR override in seconds. + smooth: Smoothing kernel FWHM in mm, or ``None`` to skip smoothing. func_template: Brain template for functional resampling (default: MNI152 2 mm). func_template_mask: Brain mask for functional masking (default: MNI152 2 mm). func_template_ref: BOLD reference image for functional masking. @@ -150,6 +155,7 @@ def run( pipe_ctx, regressors=regressors, tr=tr, + smooth=smooth, func_template=func_template, func_template_mask=func_template_mask, func_template_ref=func_template_ref, diff --git a/src/rbc/orchestration/metrics.py b/src/rbc/orchestration/metrics.py index c2042d07..14ebfbd3 100644 --- a/src/rbc/orchestration/metrics.py +++ b/src/rbc/orchestration/metrics.py @@ -47,7 +47,7 @@ def process_run( tr: float, regressor: str, atlas_files: Mapping[str, Path], - fwhm: float, + smooth: float | None = None, ) -> MetricsOutputs: """Run metrics for a single regressor on a single BOLD run. @@ -59,7 +59,7 @@ def process_run( tr: Repetition time in seconds. regressor: Regressor name. atlas_files: Mapping of atlas labels to resolved NIfTI file paths. - fwhm: Smoothing kernel FWHM in mm. + smooth: Smoothing kernel FWHM in mm. Returns: Metrics outputs for this run/regressor. @@ -70,10 +70,10 @@ def process_run( template_brain_mask=func_outputs.template_brain_mask, tr=tr, atlas_files=atlas_files, - fwhm=fwhm, + smooth=smooth, ) export_metrics( - mni, outputs, regressor=regressor, atlases=list(atlas_files), fwhm=fwhm + mni, outputs, regressor=regressor, atlases=list(atlas_files), smooth=smooth ) return outputs @@ -84,7 +84,7 @@ def run( filters: Filters, regressors: Sequence[str], atlas_files: Mapping[str, Path], - fwhm: float, + smooth: float | None = None, tr: float | None = None, runner_config: RunnerConfig | None = None, ) -> None: @@ -95,7 +95,7 @@ def run( filters: Participant/session/task filters. regressors: Regressor names. atlas_files: Mapping of atlas labels to resolved NIfTI file paths. - fwhm: Smoothing kernel FWHM in mm. + smooth: Smoothing kernel FWHM in mm. tr: TR override in seconds, or ``None`` to read from headers. runner_config: Execution backend configuration. """ @@ -142,14 +142,14 @@ def run( template_brain_mask=resolved["template_brain_mask"], tr=run_tr, atlas_files=atlas_files, - fwhm=fwhm, + smooth=smooth, ) export_metrics( mni_q, outputs, regressor=regressor, atlases=list(atlas_files), - fwhm=fwhm, + smooth=smooth, ) pipe_ctx.ensure_dataset_description() diff --git a/src/rbc/workflows/functional.py b/src/rbc/workflows/functional.py index 11f44655..a67ae8dd 100644 --- a/src/rbc/workflows/functional.py +++ b/src/rbc/workflows/functional.py @@ -34,6 +34,7 @@ slice_timing_correction, truncate_trs, ) +from rbc.core.metrics.smoothing import smooth as apply_smooth from rbc.core.niwrap import generate_exec_folder from rbc_resources import REGISTRATION_TEMPLATES @@ -82,6 +83,9 @@ class FunctionalOutputs(NamedTuple): bpf_regressor_file: Bandpass-filtered nuisance regressor ``.1D`` file, matching what ``3dTproject -bandpass`` actually applied. For BIDS export only. + cleaned_bold_smooth: Spatially smoothed nuisance-regressed + & bandpass-filtered BOLD, or *None* is no smoothing requested. + regressor_file: Bandpass-filtered nuisance regressor ``.1D`` file. template_brain_mask: Brain mask warped to template space. """ @@ -104,6 +108,7 @@ class FunctionalOutputs(NamedTuple): template_bold: Path regressed_bold: dict[str, Path] cleaned_bold: dict[str, Path] + cleaned_bold_smooth: dict[str, Path] | None regressor_file: dict[str, Path] bpf_regressor_file: dict[str, Path] template_brain_mask: Path @@ -152,6 +157,8 @@ def single_session_preprocess( func_template: Path = REGISTRATION_TEMPLATES.brain_2mm, func_template_mask: Path = REGISTRATION_TEMPLATES.brain_mask_2mm, func_template_ref: Path = REGISTRATION_TEMPLATES.bold_ref, + *, + smooth: float | None = None, ) -> FunctionalOutputs: """Run the full functional preprocessing pipeline for one session. @@ -181,6 +188,7 @@ def single_session_preprocess( 16. Nuisance regression with simultaneous bandpass filtering on template-space BOLD (Hallquist 2013). 17. Export bandpass-filtered regressors. + 18. Optionally export smoothed nuisance regressed & bandpass-filtered BOLD. Args: in_bold: Raw BOLD timeseries to preprocess. @@ -200,6 +208,9 @@ def single_session_preprocess( func_template: Brain template for functional resampling (default: MNI152 2 mm). func_template_mask: Brain mask for functional masking (default: MNI152 2 mm). func_template_ref: BOLD reference image for functional masking. + smooth: Smoothing kernel FWHM in mm, or ``None`` to skip smoothing. + Applied to cleaned BOLD after regression and bandpass filtering, + export-only. Returns: All output paths bundled in a :class:`FunctionalOutputs` tuple. @@ -369,6 +380,21 @@ def single_session_preprocess( f_high=0.1, ) + # 18. Optionally smooth cleaned BOLD (export-only, post-regression). + # Smoothing is applied after regression and bandpass filtering. + cleaned_bold_smooth: dict[str, Path] | None = None + if smooth is not None: + cleaned_bold_smooth = {} + for regressor in regressor_set: + _logger.info( + "%s smoothing cleaned BOLD (fwhm=%.1f mm)", regressor, smooth + ) + cleaned_bold_smooth[regressor] = apply_smooth( + cleaned[regressor].regressed_bold, + tmpl_brain, + fwhm=smooth, + ) + return FunctionalOutputs( reoriented_bold=reoriented.out_file, truncated_bold=truncated, @@ -389,7 +415,12 @@ 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}, +<<<<<<< HEAD regressor_file=raw_regressors, bpf_regressor_file=filtered_regressors, +======= + cleaned_bold_smooth=cleaned_bold_smooth, + regressor_file=filtered_regressors, +>>>>>>> 54227e6 (update metrics to one flag and add functional smoothing) template_brain_mask=tmpl_brain, ) diff --git a/src/rbc/workflows/metrics.py b/src/rbc/workflows/metrics.py index 594fb2f6..23632b11 100644 --- a/src/rbc/workflows/metrics.py +++ b/src/rbc/workflows/metrics.py @@ -14,7 +14,7 @@ from rbc.core.metrics.alff import compute_alff from rbc.core.metrics.reho import compute_reho -from rbc.core.metrics.smoothing import smooth +from rbc.core.metrics.smoothing import smooth as apply_smooth from rbc.core.metrics.standardization import compute_zscore from rbc.core.metrics.timeseries import compute_timeseries from rbc.core.niwrap import generate_exec_folder @@ -68,9 +68,8 @@ def single_session_metrics( template_brain_mask: Path, tr: float, atlas_files: Mapping[str, Path], - fwhm: float = 6.0, *, - apply_smooth: bool = False, + smooth: float | None = None, ) -> MetricsOutputs: """Compute all derivative metrics for a single functional run. @@ -80,9 +79,9 @@ def single_session_metrics( template_brain_mask: Brain mask warped to template space. tr: Repetition time in seconds. atlas_files: Mapping of atlas labels to resolved NIfTI file paths. - fwhm: Smoothing kernel FWHM in mm. - apply_smooth: If True, produce smoothed and z-scored variants of all - derivative maps in addition to the raw maps. + smooth: Smoothing kernel FWHM in mm, or ``None`` to skip smoothing. + Produces smoothed and z-scored variants of all derivative maps + in addition to the raw maps. Returns: All metric outputs bundled in a :class:`MetricsOutputs` tuple. @@ -117,11 +116,11 @@ def single_session_metrics( None ) - if apply_smooth: - _logger.info("Smoothing derivative maps (FWHM=%.1f mm)", fwhm) - alff_smooth_path = smooth(alff_path, template_brain_mask, fwhm=fwhm) - falff_smooth_path = smooth(falff_path, template_brain_mask, fwhm=fwhm) - reho_smooth_path = smooth(reho_path, template_brain_mask, fwhm=fwhm) + if smooth is not None: + _logger.info("Smoothing derivative maps (FWHM=%.1f mm)", smooth) + alff_smooth_path = apply_smooth(alff_path, template_brain_mask, fwhm=smooth) + falff_smooth_path = apply_smooth(falff_path, template_brain_mask, fwhm=smooth) + reho_smooth_path = apply_smooth(reho_path, template_brain_mask, fwhm=smooth) _logger.info("Z-scoring smoothed maps") alff_smooth_zscored_path = compute_zscore(alff_smooth_path, template_brain_mask) From ee2abe296646faee752187f6ef03f9446e295ebe Mon Sep 17 00:00:00 2001 From: Janhavi Pillai Date: Thu, 16 Apr 2026 11:39:00 -0400 Subject: [PATCH 04/13] mypy --- src/rbc/orchestration/all.py | 8 ++++---- src/rbc/workflows/functional.py | 4 +--- tests/unit/bids/test_exports.py | 11 +++++++---- tests/unit/cli/test_all.py | 18 +++++++++--------- tests/unit/cli/test_metrics.py | 18 +++++++++--------- tests/unit/orchestration/test_functional.py | 2 +- 6 files changed, 31 insertions(+), 30 deletions(-) diff --git a/src/rbc/orchestration/all.py b/src/rbc/orchestration/all.py index c6136f90..e36c8361 100644 --- a/src/rbc/orchestration/all.py +++ b/src/rbc/orchestration/all.py @@ -42,7 +42,7 @@ def run( filters: Filters, regressors: Sequence[str], atlas_files: Mapping[str, Path], - fwhm: float, + smooth: float | None = None, start_tr: int, tr: float | None = None, brain_extraction_templates: BrainExtractionTemplates = BRAIN_EXTRACTION_TEMPLATES, @@ -63,7 +63,7 @@ def run( filters: Participant/session/task filters. regressors: Regressor names. atlas_files: Mapping of atlas labels to resolved NIfTI file paths. - fwhm: Smoothing kernel FWHM in mm. + smooth: Smoothing kernel FWHM in mm. start_tr: Number of initial TRs discarded during preprocessing. tr: TR override in seconds, or ``None`` to read from headers. brain_extraction_templates: Brain extraction template bundle. @@ -140,14 +140,14 @@ def run( template_brain_mask=func_outputs.template_brain_mask, tr=func_metadata.tr, atlas_files=atlas_files, - fwhm=fwhm, + smooth=smooth, ) export_metrics( mni, metrics_outputs, regressor=regressor, atlases=list(atlas_files), - fwhm=fwhm, + smooth=smooth, ) # QC diff --git a/src/rbc/workflows/functional.py b/src/rbc/workflows/functional.py index a67ae8dd..4c3f6678 100644 --- a/src/rbc/workflows/functional.py +++ b/src/rbc/workflows/functional.py @@ -386,9 +386,7 @@ def single_session_preprocess( if smooth is not None: cleaned_bold_smooth = {} for regressor in regressor_set: - _logger.info( - "%s smoothing cleaned BOLD (fwhm=%.1f mm)", regressor, smooth - ) + _logger.info("%s smoothing cleaned BOLD (fwhm=%.1f mm)", regressor, smooth) cleaned_bold_smooth[regressor] = apply_smooth( cleaned[regressor].regressed_bold, tmpl_brain, diff --git a/tests/unit/bids/test_exports.py b/tests/unit/bids/test_exports.py index 35ce0f80..2837b599 100644 --- a/tests/unit/bids/test_exports.py +++ b/tests/unit/bids/test_exports.py @@ -73,6 +73,9 @@ def _make_func_outputs(w: Path, regressors: list[str]) -> FunctionalOutputs: template_bold=_dummy(w, "template_bold.nii.gz"), 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}, + cleaned_bold_smooth={ + r: _dummy(w, f"cleaned_{r}_smooth.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"regressors_bpf_{r}.1D") for r in regressors}, template_brain_mask=_dummy(w, "template_mask.nii.gz"), @@ -237,7 +240,7 @@ def test_sanitizes_atlas_labels( mni = func_bids.derive(space="MNI152NLin6Asym") outputs = _make_metrics_outputs(workdir, ["schaefer_200"]) export_metrics( - mni, outputs, regressor="36-parameter", atlases=["schaefer_200"], fwhm=6.0 + mni, outputs, regressor="36-parameter", atlases=["schaefer_200"], smooth=6.0 ) atlas_files = [ p.name for p in pipe_ctx.output_dir.rglob("*.*") if "atlas-" in p.name @@ -254,7 +257,7 @@ def test_sanitizes_regressor_labels( mni = func_bids.derive(space="MNI152NLin6Asym") outputs = _make_metrics_outputs(workdir, ["aal"]) export_metrics( - mni, outputs, regressor="36-parameter", atlases=["aal"], fwhm=6.0 + mni, outputs, regressor="36-parameter", atlases=["aal"], smooth=6.0 ) all_names = [p.name for p in pipe_ctx.output_dir.rglob("*.*")] reg_files = [n for n in all_names if "reg-" in n] @@ -269,7 +272,7 @@ def test_file_count_single_atlas( mni = func_bids.derive(space="MNI152NLin6Asym") outputs = _make_metrics_outputs(workdir, ["schaefer_200"]) export_metrics( - mni, outputs, regressor="aCompCor", atlases=["schaefer_200"], fwhm=6.0 + mni, outputs, regressor="aCompCor", atlases=["schaefer_200"], smooth=6.0 ) saved = list(pipe_ctx.output_dir.rglob("*.*")) assert len(saved) == 8 @@ -281,7 +284,7 @@ def test_file_count_multiple_atlases( atlases = ["schaefer_200", "aal"] mni = func_bids.derive(space="MNI152NLin6Asym") outputs = _make_metrics_outputs(workdir, atlases) - export_metrics(mni, outputs, regressor="aCompCor", atlases=atlases, fwhm=6.0) + export_metrics(mni, outputs, regressor="aCompCor", atlases=atlases, smooth=6.0) saved = list(pipe_ctx.output_dir.rglob("*.*")) assert len(saved) == 10 diff --git a/tests/unit/cli/test_all.py b/tests/unit/cli/test_all.py index 32b44fc7..0b21606d 100644 --- a/tests/unit/cli/test_all.py +++ b/tests/unit/cli/test_all.py @@ -64,7 +64,7 @@ def test_defaults(self, base_args: argparse.Namespace) -> None: assert args.regressor == ["36-parameter"] assert args.task is None assert "schaefer_200" in args.atlas_files - assert args.fwhm == 6.0 + assert args.smooth == 6.0 assert args.start_tr == 2 assert args.participant_label == [] assert args.session_label == [] @@ -94,20 +94,20 @@ def test_invalid_atlas_raises(self, base_args: argparse.Namespace) -> None: with pytest.raises(FileNotFoundError): AllArgs.validate_namespace(base_args) - @pytest.mark.parametrize("fwhm", [0.1, 1.0, 6.0, 10.0]) - def test_valid_fwhm(self, base_args: argparse.Namespace, fwhm: float) -> None: + @pytest.mark.parametrize("smooth", [0.1, 1.0, 6.0, 10.0]) + def test_valid_fwhm(self, base_args: argparse.Namespace, smooth: float) -> None: """Positive FWHM values pass validation.""" - base_args.fwhm = fwhm + base_args.smooth = smooth args = AllArgs.validate_namespace(base_args) - assert args.fwhm == fwhm + assert args.smooth == smooth - @pytest.mark.parametrize("fwhm", [0.0, -1.0, -6.0]) + @pytest.mark.parametrize("smooth", [0.0, -1.0, -6.0]) def test_invalid_fwhm_raises( - self, base_args: argparse.Namespace, fwhm: float + self, base_args: argparse.Namespace, smooth: float ) -> None: """Zero or negative FWHM raises ValueError.""" - base_args.fwhm = fwhm - with pytest.raises(ValueError, match="FWHM"): + base_args.smooth = smooth + with pytest.raises(ValueError, match="smooth"): AllArgs.validate_namespace(base_args) def test_custom_start_tr(self, base_args: argparse.Namespace) -> None: diff --git a/tests/unit/cli/test_metrics.py b/tests/unit/cli/test_metrics.py index 4b1cd064..9e183f31 100644 --- a/tests/unit/cli/test_metrics.py +++ b/tests/unit/cli/test_metrics.py @@ -55,7 +55,7 @@ def test_defaults(self, base_args: argparse.Namespace) -> None: args = MetricsArgs.validate_namespace(base_args) assert args.task is None assert "schaefer_200" in args.atlas_files - assert args.fwhm == 6.0 + assert args.smooth == 6.0 assert args.regressor == ["36-parameter"] assert args.participant_label == [] assert args.session_label == [] @@ -85,20 +85,20 @@ def test_invalid_atlas_raises(self, base_args: argparse.Namespace) -> None: with pytest.raises(FileNotFoundError): MetricsArgs.validate_namespace(base_args) - @pytest.mark.parametrize("fwhm", [0.1, 1.0, 6.0, 10.0]) - def test_valid_fwhm(self, base_args: argparse.Namespace, fwhm: float) -> None: + @pytest.mark.parametrize("smooth", [0.1, 1.0, 6.0, 10.0]) + def test_valid_fwhm(self, base_args: argparse.Namespace, smooth: float) -> None: """Positive FWHM values pass validation.""" - base_args.fwhm = fwhm + base_args.smooth = smooth args = MetricsArgs.validate_namespace(base_args) - assert args.fwhm == fwhm + assert args.smooth == smooth - @pytest.mark.parametrize("fwhm", [0.0, -1.0, -6.0]) + @pytest.mark.parametrize("smooth", [0.0, -1.0, -6.0]) def test_invalid_fwhm_raises( - self, base_args: argparse.Namespace, fwhm: float + self, base_args: argparse.Namespace, smooth: float ) -> None: """Zero or negative FWHM raises ValueError.""" - base_args.fwhm = fwhm - with pytest.raises(ValueError, match="FWHM"): + base_args.smooth = smooth + with pytest.raises(ValueError, match="smooth"): MetricsArgs.validate_namespace(base_args) @pytest.mark.parametrize( diff --git a/tests/unit/orchestration/test_functional.py b/tests/unit/orchestration/test_functional.py index c4834a39..3261c6bb 100644 --- a/tests/unit/orchestration/test_functional.py +++ b/tests/unit/orchestration/test_functional.py @@ -247,7 +247,7 @@ def test_anat_outputs_forwarded_as_anat_inputs(self, tmp_path: Path) -> None: filters=Filters(participant_label=["01"]), regressors=["36-parameter"], atlas_files={}, - fwhm=6.0, + smooth=6.0, start_tr=2, ) From 67693b9cf14a8a28234217f1da8b4902abaddf19 Mon Sep 17 00:00:00 2001 From: Janhavi Pillai Date: Thu, 16 Apr 2026 12:21:01 -0400 Subject: [PATCH 05/13] testing --- src/rbc/cli/all.py | 2 + src/rbc/cli/functional.py | 2 + src/rbc/cli/metrics.py | 2 + tests/unit/bids/test_exports.py | 90 ++++++++++++++++++++++++++++--- tests/unit/cli/test_all.py | 16 +++--- tests/unit/cli/test_functional.py | 1 + tests/unit/cli/test_metrics.py | 18 +++---- 7 files changed, 107 insertions(+), 24 deletions(-) diff --git a/src/rbc/cli/all.py b/src/rbc/cli/all.py index 0b5d5080..cc91bbb5 100644 --- a/src/rbc/cli/all.py +++ b/src/rbc/cli/all.py @@ -51,6 +51,8 @@ class AllArgs(BaseArgs): def validate_namespace(cls, ns: argparse.Namespace) -> AllArgs: """Validate all-workflow arguments.""" _validate_task(ns.task) + if ns.smooth is not None: + _validate_positive(ns.smooth, "smooth") _validate_positive(ns.start_tr, "Start TR") _validate_positive(ns.tr, "TR") atlas_files = _resolve_atlas_args(ns.atlas) diff --git a/src/rbc/cli/functional.py b/src/rbc/cli/functional.py index 174b3ed6..316b2b36 100644 --- a/src/rbc/cli/functional.py +++ b/src/rbc/cli/functional.py @@ -45,6 +45,8 @@ class FunctionalArgs(BaseArgs): def validate_namespace(cls, ns: argparse.Namespace) -> FunctionalArgs: """Validation of functional workflow specific arguments to NamedTuple.""" _validate_task(ns.task) + if ns.smooth is not None: + _validate_positive(ns.smooth, "smooth") _validate_positive(ns.tr, "TR") return cls( **BaseArgs.validate_namespace(ns).__dict__, diff --git a/src/rbc/cli/metrics.py b/src/rbc/cli/metrics.py index 507ebc05..78ea239b 100644 --- a/src/rbc/cli/metrics.py +++ b/src/rbc/cli/metrics.py @@ -59,6 +59,8 @@ class MetricsArgs(BaseArgs): def validate_namespace(cls, ns: argparse.Namespace) -> MetricsArgs: """Validate metrics-specific arguments.""" _validate_task(ns.task) + if ns.smooth is not None: + _validate_positive(ns.smooth, "smooth") _validate_positive(ns.tr, "TR") atlas_files = _resolve_atlas_args(ns.atlas) return cls( diff --git a/tests/unit/bids/test_exports.py b/tests/unit/bids/test_exports.py index 2837b599..03fad868 100644 --- a/tests/unit/bids/test_exports.py +++ b/tests/unit/bids/test_exports.py @@ -73,9 +73,7 @@ def _make_func_outputs(w: Path, regressors: list[str]) -> FunctionalOutputs: template_bold=_dummy(w, "template_bold.nii.gz"), 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}, - cleaned_bold_smooth={ - r: _dummy(w, f"cleaned_{r}_smooth.nii.gz") for r in regressors - }, + cleaned_bold_smooth=None, regressor_file={r: _dummy(w, f"regressors_{r}.1D") for r in regressors}, bpf_regressor_file={r: _dummy(w, f"regressors_bpf_{r}.1D") for r in regressors}, template_brain_mask=_dummy(w, "template_mask.nii.gz"), @@ -224,6 +222,31 @@ def test_file_count_two_regressors( saved = list(pipe_ctx.output_dir.rglob("*.*")) assert len(saved) == 18 + def test_bold_smooth_not_exported_if_none( + self, func_bids: Bids, workdir: Path, pipe_ctx: RunContext + ) -> None: + """No smoothed BOLD files are exported when cleaned_bold_smooth is None.""" + outputs = _make_func_outputs(workdir, ["36-parameter"]) + export_functional(func_bids, outputs, regressors=["36-parameter"]) + saved = [p.name for p in pipe_ctx.output_dir.rglob("*.nii.gz")] + assert not any("desc-sm" in name for name in saved) + + def test_cleaned_bold_smooth_exported_with_correct_desc( + self, func_bids: Bids, workdir: Path, pipe_ctx: RunContext + ) -> None: + """Smoothed BOLD is exported with correct fwhm label.""" + smooth_outputs = _make_func_outputs(workdir, ["36-parameter"])._replace( + cleaned_bold_smooth={ + "36-parameter": _dummy(workdir, "cleaned_smooth.nii.gz") + } + ) + export_functional( + func_bids, smooth_outputs, regressors=["36-parameter"], smooth=8.0 + ) + saved = [p.name for p in pipe_ctx.output_dir.rglob("*.nii.gz")] + sm_files = [n for n in saved if "desc-sm8preproc" in n] + assert len(sm_files) == 1 + # --------------------------------------------------------------------------- # Metrics exports @@ -240,7 +263,11 @@ def test_sanitizes_atlas_labels( mni = func_bids.derive(space="MNI152NLin6Asym") outputs = _make_metrics_outputs(workdir, ["schaefer_200"]) export_metrics( - mni, outputs, regressor="36-parameter", atlases=["schaefer_200"], smooth=6.0 + mni, + outputs, + regressor="36-parameter", + atlases=["schaefer_200"], + smooth=None, ) atlas_files = [ p.name for p in pipe_ctx.output_dir.rglob("*.*") if "atlas-" in p.name @@ -257,7 +284,7 @@ def test_sanitizes_regressor_labels( mni = func_bids.derive(space="MNI152NLin6Asym") outputs = _make_metrics_outputs(workdir, ["aal"]) export_metrics( - mni, outputs, regressor="36-parameter", atlases=["aal"], smooth=6.0 + mni, outputs, regressor="36-parameter", atlases=["aal"], smooth=None ) all_names = [p.name for p in pipe_ctx.output_dir.rglob("*.*")] reg_files = [n for n in all_names if "reg-" in n] @@ -272,7 +299,7 @@ def test_file_count_single_atlas( mni = func_bids.derive(space="MNI152NLin6Asym") outputs = _make_metrics_outputs(workdir, ["schaefer_200"]) export_metrics( - mni, outputs, regressor="aCompCor", atlases=["schaefer_200"], smooth=6.0 + mni, outputs, regressor="aCompCor", atlases=["schaefer_200"], smooth=None ) saved = list(pipe_ctx.output_dir.rglob("*.*")) assert len(saved) == 8 @@ -284,10 +311,59 @@ def test_file_count_multiple_atlases( atlases = ["schaefer_200", "aal"] mni = func_bids.derive(space="MNI152NLin6Asym") outputs = _make_metrics_outputs(workdir, atlases) - export_metrics(mni, outputs, regressor="aCompCor", atlases=atlases, smooth=6.0) + export_metrics(mni, outputs, regressor="aCompCor", atlases=atlases, smooth=None) saved = list(pipe_ctx.output_dir.rglob("*.*")) assert len(saved) == 10 + def test_file_count_single_atlas_with_smooth( + self, func_bids: Bids, workdir: Path, pipe_ctx: RunContext + ) -> None: + """3 raw + 3 zscored + 3 smooth + 3 smooth zscored + 2 atlas files = 14.""" + mni = func_bids.derive(space="MNI152NLin6Asym") + smooth_outputs = _make_metrics_outputs(workdir, ["schaefer_200"])._replace( + alff_smooth=_dummy(workdir, "alff_sm.nii.gz"), + falff_smooth=_dummy(workdir, "falff_sm.nii.gz"), + reho_smooth=_dummy(workdir, "reho_sm.nii.gz"), + alff_smooth_zscored=_dummy(workdir, "alff_sm_z.nii.gz"), + falff_smooth_zscored=_dummy(workdir, "falff_sm_z.nii.gz"), + reho_smooth_zscored=_dummy(workdir, "reho_sm_z.nii.gz"), + ) + export_metrics( + mni, + smooth_outputs, + regressor="aCompCor", + atlases=["schaefer_200"], + smooth=6.0, + ) + saved = list(pipe_ctx.output_dir.rglob("*.*")) + assert len(saved) == 14 + + def test_smooth_maps_have_correct_desc( + self, func_bids: Bids, workdir: Path, pipe_ctx: RunContext + ) -> None: + """Smoothed maps are exported with correct fwhm labels.""" + mni = func_bids.derive(space="MNI152NLin6Asym") + smooth_outputs = _make_metrics_outputs(workdir, ["schaefer_200"])._replace( + alff_smooth=_dummy(workdir, "alff_sm.nii.gz"), + falff_smooth=_dummy(workdir, "falff_sm.nii.gz"), + reho_smooth=_dummy(workdir, "reho_sm.nii.gz"), + alff_smooth_zscored=_dummy(workdir, "alff_sm_z.nii.gz"), + falff_smooth_zscored=_dummy(workdir, "falff_sm_z.nii.gz"), + reho_smooth_zscored=_dummy(workdir, "reho_sm_z.nii.gz"), + ) + export_metrics( + mni, + smooth_outputs, + regressor="aCompCor", + atlases=["schaefer_200"], + smooth=8.0, + ) + saved = [p.name for p in pipe_ctx.output_dir.rglob("*.nii.gz")] + sm_only = [n for n in saved if "desc-sm8" in n and "Zstd" not in n] + sm_zstd = [n for n in saved if "desc-sm8Zstd" in n] + assert len(sm_only) == 3 + assert len(sm_zstd) == 3 + # --------------------------------------------------------------------------- # QC exports diff --git a/tests/unit/cli/test_all.py b/tests/unit/cli/test_all.py index 0b21606d..54f48379 100644 --- a/tests/unit/cli/test_all.py +++ b/tests/unit/cli/test_all.py @@ -30,7 +30,7 @@ def base_args(tmp_path: Path) -> argparse.Namespace: regressor=["36-parameter"], task=None, atlas=["schaefer_200"], - fwhm=6.0, + smooth=None, start_tr=2, tr=None, tmp_dir=None, @@ -64,7 +64,7 @@ def test_defaults(self, base_args: argparse.Namespace) -> None: assert args.regressor == ["36-parameter"] assert args.task is None assert "schaefer_200" in args.atlas_files - assert args.smooth == 6.0 + assert args.smooth is None assert args.start_tr == 2 assert args.participant_label == [] assert args.session_label == [] @@ -184,11 +184,11 @@ def test_all_parser_atlas_choices(self) -> None: args = parser.parse_args(["all", "/input", "-o", "/output", "--atlas", "aal"]) assert args.atlas == ["aal"] - def test_all_parser_has_fwhm(self) -> None: - """Test all subparser includes --fwhm argument.""" + def test_all_parser_has_smooth(self) -> None: + """Test all subparser includes --smooth argument.""" parser = create_parser() - args = parser.parse_args(["all", "/input", "-o", "/output", "--fwhm", "8.0"]) - assert args.fwhm == 8.0 + args = parser.parse_args(["all", "/input", "-o", "/output", "--smooth", "8.0"]) + assert args.smooth == 8.0 def test_all_parser_has_start_tr(self) -> None: """Test all subparser includes --start-tr argument.""" @@ -203,10 +203,10 @@ def test_all_parser_task_default_none(self) -> None: assert args.task is None def test_all_parser_fwhm_default(self) -> None: - """Test all subparser --fwhm defaults to 6.0.""" + """Test all subparser --smooth defaults to None.""" parser = create_parser() args = parser.parse_args(["all", "/input", "-o", "/output"]) - assert args.fwhm == 6.0 + assert args.smooth is None def test_all_parser_start_tr_default(self) -> None: """Test all subparser --start-tr defaults to 2.""" diff --git a/tests/unit/cli/test_functional.py b/tests/unit/cli/test_functional.py index e2242cf8..7ea76429 100644 --- a/tests/unit/cli/test_functional.py +++ b/tests/unit/cli/test_functional.py @@ -33,6 +33,7 @@ def func_namespace(self, tmp_path: Path) -> argparse.Namespace: regressor=["36-parameter"], task=None, tr=None, + smooth=None, tmp_dir=None, func_template=None, func_template_mask=None, diff --git a/tests/unit/cli/test_metrics.py b/tests/unit/cli/test_metrics.py index 9e183f31..c01c1203 100644 --- a/tests/unit/cli/test_metrics.py +++ b/tests/unit/cli/test_metrics.py @@ -29,7 +29,7 @@ def base_args(tmp_path: Path) -> argparse.Namespace: session_label=[], task=None, atlas=["schaefer_200"], - fwhm=6.0, + smooth=None, regressor=["36-parameter"], tr=None, tmp_dir=None, @@ -55,7 +55,7 @@ def test_defaults(self, base_args: argparse.Namespace) -> None: args = MetricsArgs.validate_namespace(base_args) assert args.task is None assert "schaefer_200" in args.atlas_files - assert args.smooth == 6.0 + assert args.smooth is None assert args.regressor == ["36-parameter"] assert args.participant_label == [] assert args.session_label == [] @@ -150,13 +150,13 @@ def test_metrics_parser_atlas_choices(self) -> None: ) assert args.atlas == ["aal"] - def test_metrics_parser_has_fwhm(self) -> None: - """Test metrics subparser includes --fwhm argument.""" + def test_metrics_parser_has_smooth(self) -> None: + """Test metrics subparser includes --smooth argument.""" parser = create_parser() args = parser.parse_args( - ["metrics", "/input", "-o", "/output", "--fwhm", "8.0"] + ["metrics", "/input", "-o", "/output", "--smooth", "8.0"] ) - assert args.fwhm == 8.0 + assert args.smooth == 8.0 def test_metrics_parser_has_task(self) -> None: """Test metrics subparser includes --task argument.""" @@ -178,11 +178,11 @@ def test_metrics_parser_task_default_none(self) -> None: args = parser.parse_args(["metrics", "/input", "-o", "/output"]) assert args.task is None - def test_metrics_parser_fwhm_default(self) -> None: - """Test metrics subparser --fwhm defaults to 6.0.""" + def test_metrics_parser_smooth_default(self) -> None: + """Test metrics subparser --smooth defaults to None.""" parser = create_parser() args = parser.parse_args(["metrics", "/input", "-o", "/output"]) - assert args.fwhm == 6.0 + assert args.smooth is None class TestResolveAtlasArgs: From 2a3aa8e6e1c385d4426cd72580f14a6409f04cea Mon Sep 17 00:00:00 2001 From: Janhavi Pillai Date: Fri, 17 Apr 2026 13:49:52 -0400 Subject: [PATCH 06/13] changes --- src/rbc/bids/functional.py | 18 ++++++++- src/rbc/bids/metrics.py | 20 +++++++++- src/rbc/core/common.py | 35 ++++++++++++++++- src/rbc/core/metrics/smoothing.py | 43 --------------------- src/rbc/workflows/functional.py | 4 +- src/rbc/workflows/metrics.py | 2 +- tests/integration/metrics/test_smoothing.py | 3 +- tests/unit/bids/test_exports.py | 6 ++- 8 files changed, 77 insertions(+), 54 deletions(-) delete mode 100644 src/rbc/core/metrics/smoothing.py diff --git a/src/rbc/bids/functional.py b/src/rbc/bids/functional.py index 071fe3aa..b0a817b7 100644 --- a/src/rbc/bids/functional.py +++ b/src/rbc/bids/functional.py @@ -17,6 +17,22 @@ from rbc.workflows.functional import FunctionalOutputs +def _smooth_label(fwhm: float, precision: int | None = None) -> str: + """Format FWHM as a BIDS-safe label (e.g. 6.0 -> 'sm6', 0.1 -> 'sm0p1'). + + Trailing zeros are stripped and '.' is replaced with 'p' for BIDS compliance. + + Args: + fwhm: Smoothing kernel FWHM in mm. + precision: Optional number of decimal places to format to before stripping. + + Returns: + BIDS-safe label string (e.g. 'sm6', 'sm0p1'). + """ + s = f"{fwhm:.{precision}f}" if precision is not None else str(fwhm) + return "sm" + s.rstrip("0").rstrip(".").replace(".", "p") + + class FunctionalRun(NamedTuple): """A single functional run discovered from a BIDS session. @@ -186,7 +202,7 @@ def export_functional( mni.save( outputs.cleaned_bold_smooth[reg], suffix=Suffix.BOLD, - desc=f"sm{int(smooth)}preproc", + desc=f"{_smooth_label(smooth)}preproc", extra={"reg": bids_safe_label(reg)}, ) mni.save(outputs.template_bold, suffix=Suffix.BOLD, desc="preproc") diff --git a/src/rbc/bids/metrics.py b/src/rbc/bids/metrics.py index 52695c61..68e5326c 100644 --- a/src/rbc/bids/metrics.py +++ b/src/rbc/bids/metrics.py @@ -16,6 +16,22 @@ from rbc.workflows.metrics import MetricsOutputs +def _smooth_label(fwhm: float, precision: int | None = None) -> str: + """Format FWHM as a BIDS-safe label (e.g. 6.0 -> 'sm6', 0.1 -> 'sm0p1'). + + Trailing zeros are stripped and '.' is replaced with 'p' for BIDS compliance. + + Args: + fwhm: Smoothing kernel FWHM in mm. + precision: Optional number of decimal places to format to before stripping. + + Returns: + BIDS-safe label string (e.g. 'sm6', 'sm0p1'). + """ + s = f"{fwhm:.{precision}f}" if precision is not None else str(fwhm) + return "sm" + s.rstrip("0").rstrip(".").replace(".", "p") + + class MetricsInputs(TypedDict): """Resolved functional inputs for the metrics workflow.""" @@ -70,7 +86,7 @@ def export_metrics( Raw and z-scored raw maps are always exported. Smoothed and z-scored smoothed variants are exported only when the corresponding - fields are not None (i.e. when ``smooth=True`` was passed to + fields are not None (i.e. when ``smooth`` is not ``None`` in ``single_session_metrics``). Args: @@ -96,7 +112,7 @@ def export_metrics( # Smoothed + z-scored smoothed if smooth is not None: - sm_desc = f"sm{int(smooth)}" + sm_desc = _smooth_label(smooth) if outputs.alff_smooth is not None: mex.save(outputs.alff_smooth, suffix="alff", desc=sm_desc) assert outputs.alff_smooth_zscored is not None # noqa: S101 diff --git a/src/rbc/core/common.py b/src/rbc/core/common.py index fd3f9d61..6fa3aa83 100644 --- a/src/rbc/core/common.py +++ b/src/rbc/core/common.py @@ -1,8 +1,9 @@ -"""Processing steps shared across anatomical and functional streams. +"""Processing steps shared across anatomical, functional, and metrics streams. Currently provides: - Deobliquing and RPI reorientation (initial preprocessing for T1w and BOLD). - 4D NIfTI splitting and merging utilities. +- Spatially smooth a 3D map or 4D timeseries to a target FWHM. """ from __future__ import annotations @@ -21,7 +22,7 @@ from rbc.core.nifti import strip_afni_volatile_metadata from rbc.core.niwrap import generate_exec_folder -__all__ = ["deoblique_and_reorient", "merge_3d_to_4d", "split_4d"] +__all__ = ["deoblique_and_reorient", "merge_3d_to_4d", "smooth", "split_4d"] def deoblique_and_reorient( @@ -92,3 +93,33 @@ def merge_3d_to_4d(volumes: Sequence[Path], output: Path) -> Path: merged = nib.funcs.concat_images(imgs, axis=None) nib.save(merged, output) return output + + +def smooth( + in_file: Path, + mask_file: Path, + fwhm: float = 6.0, +) -> Path: + """Spatially smooth a 3D map or 4D timeseries to a target FWHM. + + Uses AFNI ``3dBlurToFWHM`` to iteratively blur the input until the + estimated smoothness reaches the requested FWHM within the brain mask. + Supports both 3D derivative maps (ALFF, fALFF, ReHo) and 4D BOLD + timeseries. + + Args: + in_file: NIfTI image to smooth (3-D map or 4-D timeseries). + mask_file: Binary brain mask; voxels outside are set to zero. + fwhm: Target full-width at half-maximum in mm. + + Returns: + Path to the smoothed image. + """ + result = afni.v_3d_blur_to_fwhm( + in_file=in_file, + mask=mask_file, + fwhm=fwhm, + prefix="smoothed.nii.gz", + ) + assert result.out_file is not None # noqa: S101 + return result.out_file diff --git a/src/rbc/core/metrics/smoothing.py b/src/rbc/core/metrics/smoothing.py deleted file mode 100644 index 5e8b430a..00000000 --- a/src/rbc/core/metrics/smoothing.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Spatial smoothing of derivative maps. - -Applies iterative Gaussian smoothing via AFNI ``3dBlurToFWHM`` to bring -derivative maps (ALFF, fALFF, ReHo, centrality) to a target spatial -smoothness within a brain mask. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from niwrap import afni - -if TYPE_CHECKING: - from pathlib import Path - - -def smooth( - in_file: Path, - mask_file: Path, - fwhm: float = 6.0, -) -> Path: - """Spatially smooth a 3D map to a target FWHM within a brain mask. - - Uses AFNI 3dBlurToFWHM to iteratively blur the input until the - estimated smoothness reaches the requested FWHM. - - Args: - in_file: 3D NIfTI derivative map to smooth. - mask_file: Binary brain mask; voxels outside are set to zero. - fwhm: Target full-width at half-maximum in mm. - - Returns: - Path to the smoothed map. - """ - result = afni.v_3d_blur_to_fwhm( - in_file=in_file, - mask=mask_file, - fwhm=fwhm, - prefix="smoothed.nii.gz", - ) - assert result.out_file is not None # noqa: S101 - return result.out_file diff --git a/src/rbc/workflows/functional.py b/src/rbc/workflows/functional.py index 4c3f6678..34a7b970 100644 --- a/src/rbc/workflows/functional.py +++ b/src/rbc/workflows/functional.py @@ -14,6 +14,7 @@ from niwrap import ants from rbc.core.common import deoblique_and_reorient +from rbc.core.common import smooth as apply_smooth from rbc.core.fsl2itk import mat_to_itk from rbc.core.functional import ( PEPolarFieldmap, @@ -34,7 +35,6 @@ slice_timing_correction, truncate_trs, ) -from rbc.core.metrics.smoothing import smooth as apply_smooth from rbc.core.niwrap import generate_exec_folder from rbc_resources import REGISTRATION_TEMPLATES @@ -84,7 +84,7 @@ class FunctionalOutputs(NamedTuple): file, matching what ``3dTproject -bandpass`` actually applied. For BIDS export only. cleaned_bold_smooth: Spatially smoothed nuisance-regressed - & bandpass-filtered BOLD, or *None* is no smoothing requested. + & bandpass-filtered BOLD, or *None* if no smoothing requested. regressor_file: Bandpass-filtered nuisance regressor ``.1D`` file. template_brain_mask: Brain mask warped to template space. """ diff --git a/src/rbc/workflows/metrics.py b/src/rbc/workflows/metrics.py index 23632b11..9541de3c 100644 --- a/src/rbc/workflows/metrics.py +++ b/src/rbc/workflows/metrics.py @@ -12,9 +12,9 @@ import logging from typing import TYPE_CHECKING, NamedTuple +from rbc.core.common import smooth as apply_smooth from rbc.core.metrics.alff import compute_alff from rbc.core.metrics.reho import compute_reho -from rbc.core.metrics.smoothing import smooth as apply_smooth from rbc.core.metrics.standardization import compute_zscore from rbc.core.metrics.timeseries import compute_timeseries from rbc.core.niwrap import generate_exec_folder diff --git a/tests/integration/metrics/test_smoothing.py b/tests/integration/metrics/test_smoothing.py index c8b409b2..ffd99d41 100644 --- a/tests/integration/metrics/test_smoothing.py +++ b/tests/integration/metrics/test_smoothing.py @@ -7,8 +7,7 @@ import nibabel as nib import pytest -from rbc.core.common import deoblique_and_reorient -from rbc.core.metrics.smoothing import smooth +from rbc.core.common import deoblique_and_reorient, smooth if TYPE_CHECKING: from conftest import TestSubjectData diff --git a/tests/unit/bids/test_exports.py b/tests/unit/bids/test_exports.py index 03fad868..243def91 100644 --- a/tests/unit/bids/test_exports.py +++ b/tests/unit/bids/test_exports.py @@ -13,7 +13,7 @@ from rbc.bids.anatomical import export_anatomical from rbc.bids.functional import export_functional -from rbc.bids.metrics import export_metrics +from rbc.bids.metrics import _smooth_label, export_metrics from rbc.bids.qc import export_qc from rbc.context import RunContext from rbc.workflows.anatomical import AnatomicalOutputs @@ -364,6 +364,10 @@ def test_smooth_maps_have_correct_desc( assert len(sm_only) == 3 assert len(sm_zstd) == 3 + def test_smooth_label_non_integer(self) -> None: + """Non-integer FWHM values are formatted with 'p' instead of '.'.""" + assert _smooth_label(0.1) == "sm0p1" + # --------------------------------------------------------------------------- # QC exports From b7ad032b19c590106201772534d0cce4dbd39edf Mon Sep 17 00:00:00 2001 From: Janhavi Pillai Date: Fri, 17 Apr 2026 13:56:00 -0400 Subject: [PATCH 07/13] output fix --- src/rbc/workflows/functional.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/rbc/workflows/functional.py b/src/rbc/workflows/functional.py index 34a7b970..9afa7240 100644 --- a/src/rbc/workflows/functional.py +++ b/src/rbc/workflows/functional.py @@ -413,12 +413,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}, -<<<<<<< HEAD regressor_file=raw_regressors, bpf_regressor_file=filtered_regressors, -======= cleaned_bold_smooth=cleaned_bold_smooth, - regressor_file=filtered_regressors, ->>>>>>> 54227e6 (update metrics to one flag and add functional smoothing) template_brain_mask=tmpl_brain, ) From d8d3ffd3dbd8f5d3cb4232761bc80062a96c6a8b Mon Sep 17 00:00:00 2001 From: Janhavi Pillai Date: Fri, 17 Apr 2026 14:08:59 -0400 Subject: [PATCH 08/13] longitudinal smooth --- src/rbc/cli/longitudinal/all.py | 17 ++++++++------- src/rbc/cli/longitudinal/metrics.py | 17 ++++++++------- src/rbc/orchestration/longitudinal/all.py | 7 ++++--- src/rbc/orchestration/longitudinal/metrics.py | 21 ++++++++++++------- tests/unit/cli/test_longitudinal.py | 16 +++++++------- 5 files changed, 46 insertions(+), 32 deletions(-) diff --git a/src/rbc/cli/longitudinal/all.py b/src/rbc/cli/longitudinal/all.py index 7f0f27e8..e5b3f3ee 100644 --- a/src/rbc/cli/longitudinal/all.py +++ b/src/rbc/cli/longitudinal/all.py @@ -29,14 +29,15 @@ class AllLongArgs(LongitudinalBaseArgs): registration_template: Path atlas_files: dict[str, Path] - fwhm: float + smooth: float | None regressor: Sequence[Literal["36-parameter", "aCompCor"]] task: str | None @classmethod def validate_namespace(cls, ns: argparse.Namespace) -> AllLongArgs: """Validate namespace for the full longitudinal pipeline subcommand.""" - _validate_positive(ns.fwhm, "FWHM") + if ns.smooth is not None: + _validate_positive(ns.smooth, "smooth") _validate_task(ns.task) return cls( **LongitudinalBaseArgs.validate_namespace(ns).__dict__, @@ -44,7 +45,7 @@ def validate_namespace(cls, ns: argparse.Namespace) -> AllLongArgs: ns.anat_template, REGISTRATION_TEMPLATES.brain_1mm ), atlas_files=_resolve_atlas_args(ns.atlas), - fwhm=ns.fwhm, + smooth=ns.smooth, regressor=ns.regressor, task=ns.task, ) @@ -63,7 +64,7 @@ def main(args: AllLongArgs) -> int: regressors=args.regressor, fs_license=args.fs_license, atlas_files=args.atlas_files, - fwhm=args.fwhm, + smooth=args.smooth, runner_config=RunnerConfig( runner=args.runner, verbose=bool(args.verbose), @@ -117,10 +118,12 @@ def register_command( ), ) parser.add_argument( - "--fwhm", + "--smooth", type=float, - default=6.0, - help="Smoothing kernel FWHM in mm.", + default=None, + metavar="FWHM", + help="Smoothing with the kernel of specified FWHM in mm (e.g. --smooth 6.0) " + "If omitted, no smoothing is applied.", ) templates = parser.add_argument_group("template overrides") diff --git a/src/rbc/cli/longitudinal/metrics.py b/src/rbc/cli/longitudinal/metrics.py index a09a0bd3..b2a024aa 100644 --- a/src/rbc/cli/longitudinal/metrics.py +++ b/src/rbc/cli/longitudinal/metrics.py @@ -23,7 +23,7 @@ class MetricsLongArgs(LongitudinalBaseArgs): """Arguments for ``rbc longitudinal metrics``.""" atlas_files: dict[str, Path] - fwhm: float + smooth: float | None tr: float | None task: str | None regressor: Sequence[Literal["36-parameter", "aCompCor"]] @@ -32,12 +32,13 @@ class MetricsLongArgs(LongitudinalBaseArgs): def validate_namespace(cls, ns: argparse.Namespace) -> MetricsLongArgs: """Validate namespace for the longitudinal metrics subcommand.""" _validate_task(ns.task) - _validate_positive(ns.fwhm, "FWHM") + if ns.smooth is not None: + _validate_positive(ns.smooth, "smooth") _validate_positive(ns.tr, "TR") return cls( **LongitudinalBaseArgs.validate_namespace(ns).__dict__, atlas_files=_resolve_atlas_args(ns.atlas), - fwhm=ns.fwhm, + smooth=ns.smooth, tr=ns.tr, task=ns.task, regressor=ns.regressor, @@ -56,7 +57,7 @@ def main(args: MetricsLongArgs) -> int: ), regressors=args.regressor, atlas_files=args.atlas_files, - fwhm=args.fwhm, + smooth=args.smooth, tr=args.tr, runner_config=RunnerConfig( runner=args.runner, @@ -105,10 +106,12 @@ def register_command( ), ) parser.add_argument( - "--fwhm", + "--smooth", type=float, - default=6.0, - help="Smoothing kernel FWHM in mm.", + default=None, + metavar="FWHM", + help="Smoothing with the kernel of specified FWHM in mm (e.g. --smooth 6.0) " + "If omitted, no smoothing is applied.", ) parser.add_argument( "--tr", diff --git a/src/rbc/orchestration/longitudinal/all.py b/src/rbc/orchestration/longitudinal/all.py index bd8902d0..77e92d7c 100644 --- a/src/rbc/orchestration/longitudinal/all.py +++ b/src/rbc/orchestration/longitudinal/all.py @@ -50,7 +50,7 @@ def run( regressors: Sequence[Literal["36-parameter", "aCompCor"]] = ("36-parameter",), fs_license: Path | None = None, atlas_files: Mapping[str, Path] | None = None, - fwhm: float = 6.0, + smooth: float | None = None, runner_config: RunnerConfig | None = None, ) -> None: """Run the full longitudinal pipeline (template -> anat -> func -> metrics -> qc). @@ -68,7 +68,7 @@ def run( fs_license: Optional FreeSurfer license for template generation. atlas_files: Mapping of atlas labels to NIfTI file paths. If ``None``, metrics are skipped. - fwhm: Smoothing kernel FWHM in mm. + smooth: Smoothing kernel FWHM in mm, or ``None`` to skip smoothing. runner_config: Execution backend configuration. """ config = runner_config or RunnerConfig() @@ -144,13 +144,14 @@ def run( template_brain_mask=func_outputs.bold_mask, tr=tr, atlas_files=atlas_files, - fwhm=fwhm, + smooth=smooth, ) export_metrics( func_long, metrics_outputs, regressor=regressor, atlases=list(atlas_files), + smooth=smooth, ) # QC (in-memory from func_outputs + anat_outputs) diff --git a/src/rbc/orchestration/longitudinal/metrics.py b/src/rbc/orchestration/longitudinal/metrics.py index 8137eb54..35543559 100644 --- a/src/rbc/orchestration/longitudinal/metrics.py +++ b/src/rbc/orchestration/longitudinal/metrics.py @@ -58,7 +58,7 @@ def process_metrics( tr: float, regressor: str, atlas_files: Mapping[str, Path], - fwhm: float, + smooth: float | None = None, ) -> MetricsOutputs: """Run metrics for a single regressor on a single longitudinal BOLD run. @@ -71,7 +71,7 @@ def process_metrics( tr: Repetition time in seconds. regressor: Regressor name. atlas_files: Mapping of atlas labels to resolved NIfTI file paths. - fwhm: Smoothing kernel FWHM in mm. + smooth: Smoothing kernel FWHM in mm, or ``None`` to skip smoothing. Returns: Metrics outputs for this run/regressor. @@ -82,9 +82,15 @@ def process_metrics( template_brain_mask=func_outputs.bold_mask, tr=tr, atlas_files=atlas_files, - fwhm=fwhm, + smooth=smooth, + ) + export_metrics( + func_long, + outputs, + regressor=regressor, + atlases=list(atlas_files), + smooth=smooth, ) - export_metrics(func_long, outputs, regressor=regressor, atlases=list(atlas_files)) return outputs @@ -95,7 +101,7 @@ def run( filters: Filters, regressors: Sequence[str], atlas_files: Mapping[str, Path], - fwhm: float, + smooth: float | None = None, tr: float | None = None, runner_config: RunnerConfig | None = None, ) -> None: @@ -113,7 +119,7 @@ def run( filters: Participant/session/task filters. regressors: Regressor strategy names to compute metrics for. atlas_files: Mapping of atlas labels to resolved NIfTI file paths. - fwhm: Smoothing kernel FWHM in mm. + smooth: Smoothing kernel FWHM in mm, or ``None`` to skip smoothing. tr: TR override in seconds, or ``None`` to read from headers. runner_config: Execution backend configuration. """ @@ -152,13 +158,14 @@ def run( template_brain_mask=resolved["template_brain_mask"], tr=run_tr, atlas_files=atlas_files, - fwhm=fwhm, + smooth=smooth, ) export_metrics( func_long_q, outputs, regressor=regressor, atlases=list(atlas_files), + smooth=smooth, ) pipe_ctx.ensure_dataset_description() diff --git a/tests/unit/cli/test_longitudinal.py b/tests/unit/cli/test_longitudinal.py index 1bcc62ac..ef2738e2 100644 --- a/tests/unit/cli/test_longitudinal.py +++ b/tests/unit/cli/test_longitudinal.py @@ -103,26 +103,26 @@ class TestMetricsLongArgs: """Tests for the metrics longitudinal subcommand validator.""" def test_defaults(self, base_ns: argparse.Namespace) -> None: - """FWHM defaults to 6 mm and atlas resolves from the registry.""" + """Smooth defaults to None and atlas resolves from the registry.""" base_ns.atlas = ["schaefer_200"] - base_ns.fwhm = 6.0 + base_ns.smooth = None base_ns.tr = None base_ns.task = None base_ns.regressor = ["36-parameter"] args = MetricsLongArgs.validate_namespace(base_ns) - assert args.fwhm == 6.0 + assert args.smooth is None assert args.tr is None assert "schaefer_200" in args.atlas_files assert args.regressor == ["36-parameter"] def test_nonpositive_fwhm_rejected(self, base_ns: argparse.Namespace) -> None: - """FWHM must be strictly positive.""" + """Smooth must be strictly positive.""" base_ns.atlas = ["schaefer_200"] - base_ns.fwhm = 0.0 + base_ns.smooth = 0.0 base_ns.tr = None base_ns.task = None base_ns.regressor = ["36-parameter"] - with pytest.raises(ValueError, match="FWHM"): + with pytest.raises(ValueError, match="smooth"): MetricsLongArgs.validate_namespace(base_ns) @@ -133,11 +133,11 @@ def test_defaults(self, base_ns: argparse.Namespace) -> None: """Defaults resolve to atlas registry + bundled 1 mm template.""" base_ns.anat_template = None base_ns.atlas = ["schaefer_200"] - base_ns.fwhm = 6.0 + base_ns.smooth = None base_ns.regressor = ["36-parameter"] base_ns.task = None args = AllLongArgs.validate_namespace(base_ns) - assert args.fwhm == 6.0 + assert args.smooth is None assert "schaefer_200" in args.atlas_files assert args.registration_template.name.endswith(".nii.gz") assert args.regressor == ["36-parameter"] From 576de70aefd7fa1603456bde8f260dc6e61290ae Mon Sep 17 00:00:00 2001 From: Janhavi Pillai Date: Fri, 17 Apr 2026 14:45:01 -0400 Subject: [PATCH 09/13] longitudinal: additional smoothing for func --- src/rbc/bids/longitudinal/functional.py | 24 ++++++++++++++++++ src/rbc/cli/longitudinal/functional.py | 15 ++++++++++- .../orchestration/longitudinal/functional.py | 9 +++++-- src/rbc/workflows/longitudinal/functional.py | 25 ++++++++++++++++++- 4 files changed, 69 insertions(+), 4 deletions(-) diff --git a/src/rbc/bids/longitudinal/functional.py b/src/rbc/bids/longitudinal/functional.py index 0ac9d457..b202ada3 100644 --- a/src/rbc/bids/longitudinal/functional.py +++ b/src/rbc/bids/longitudinal/functional.py @@ -15,6 +15,21 @@ from rbc.bids import Bids from rbc.workflows.longitudinal.functional import FunctionalLongOutputs +def _smooth_label(fwhm: float, precision: int | None = None) -> str: + """Format FWHM as a BIDS-safe label (e.g. 6.0 -> 'sm6', 0.1 -> 'sm0p1'). + + Trailing zeros are stripped and '.' is replaced with 'p' for BIDS compliance. + + Args: + fwhm: Smoothing kernel FWHM in mm. + precision: Optional number of decimal places to format to before stripping. + + Returns: + BIDS-safe label string (e.g. 'sm6', 'sm0p1'). + """ + s = f"{fwhm:.{precision}f}" if precision is not None else str(fwhm) + return "sm" + s.rstrip("0").rstrip(".").replace(".", "p") + def resolve_longitudinal_func( func_q: Bids, @@ -81,6 +96,7 @@ def export_longitudinal_func( outputs: FunctionalLongOutputs, *, regressors: Sequence[str], + smooth: float | None = None, ) -> None: """Export longitudinal functional outputs. @@ -88,6 +104,7 @@ def export_longitudinal_func( fex: Bids builder with ``space="longitudinal"`` and identity entities. outputs: Results from the longitudinal functional workflow. regressors: Regressor strategy names that were applied. + smooth: Smoothing kernel FWHM in mm, or ``None`` if not requested. """ fex.save(outputs.sbref, suffix=Suffix.SBREF) fex.save(outputs.bold, suffix=Suffix.BOLD, desc="preproc") @@ -112,3 +129,10 @@ def export_longitudinal_func( desc="preproc", extra={"reg": bids_safe_label(reg)}, ) + if outputs.cleaned_bold_smooth is not None and smooth is not None: + fex.save( + outputs.cleaned_bold_smooth[reg], + suffix=Suffix.BOLD, + desc=f"{_smooth_label(smooth)}preproc", + extra={"reg": bids_safe_label(reg)}, + ) diff --git a/src/rbc/cli/longitudinal/functional.py b/src/rbc/cli/longitudinal/functional.py index c1214557..88fc000b 100644 --- a/src/rbc/cli/longitudinal/functional.py +++ b/src/rbc/cli/longitudinal/functional.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Literal -from rbc.cli.base import _validate_task +from rbc.cli.base import _validate_task, _validate_positive from rbc.cli.longitudinal._base import LongitudinalBaseArgs, add_fs_license_argument from rbc.orchestration import Filters, RunnerConfig from rbc.orchestration.longitudinal.functional import run @@ -21,15 +21,19 @@ class FunctionalLongArgs(LongitudinalBaseArgs): task: str | None regressor: Sequence[Literal["36-parameter", "aCompCor"]] + smooth: float | None @classmethod def validate_namespace(cls, ns: argparse.Namespace) -> FunctionalLongArgs: """Validate namespace for the longitudinal functional subcommand.""" _validate_task(ns.task) + if ns.smooth is not None: + _validate_positive(ns.smooth, "smooth") return cls( **LongitudinalBaseArgs.validate_namespace(ns).__dict__, task=ns.task, regressor=ns.regressor, + smooth=ns.smooth, ) @@ -44,6 +48,7 @@ def main(args: FunctionalLongArgs) -> int: task=args.task, ), regressors=args.regressor, + smooth=args.smooth, runner_config=RunnerConfig( runner=args.runner, verbose=bool(args.verbose), @@ -78,6 +83,14 @@ def register_command( default=None, help="Task label to filter BOLD runs (without 'task-' prefix).", ) + parser.add_argument( + "--smooth", + type=float, + default=None, + metavar="FWHM", + help="Smoothing with the kernel of specified FWHM in mm (e.g. --smooth 6.0) " + "If omitted, no smoothing is applied.", + ) parser.add_argument( "--regressor", nargs="+", diff --git a/src/rbc/orchestration/longitudinal/functional.py b/src/rbc/orchestration/longitudinal/functional.py index dc971b38..2ed0d9dd 100644 --- a/src/rbc/orchestration/longitudinal/functional.py +++ b/src/rbc/orchestration/longitudinal/functional.py @@ -40,6 +40,7 @@ def process_func( tpl_df: pl.DataFrame, *, regressors: Sequence[Literal["36-parameter", "aCompCor"]] = ("36-parameter",), + smooth: float | None = None, ) -> FunctionalLongOutputs: """Handle functional longitudinal processing for one BOLD run. @@ -48,6 +49,7 @@ def process_func( func_df: Functional derivative DataFrame for this run. tpl_df: Longitudinal template DataFrame. regressors: Regressor strategies to apply in longitudinal space. + smooth: Smoothing kernel FWHM in mm, or ``None`` to skip smoothing. Returns: Workflow outputs for in-memory handoff to downstream stages. @@ -66,9 +68,9 @@ def process_func( ses=pipe_ctx.ses, # type: ignore[arg-type] regressors=regressors, ) - func_outputs = functional_longitudinal(**resolved) # type: ignore[arg-type] + func_outputs = functional_longitudinal(**resolved, smooth=smooth) # type: ignore[arg-type] fex = func_q.derive(space="longitudinal") - export_longitudinal_func(fex, func_outputs, regressors=regressors) + export_longitudinal_func(fex, func_outputs, regressors=regressors, smooth=smooth) return func_outputs @@ -78,6 +80,7 @@ def run( *, filters: Filters, regressors: Sequence[Literal["36-parameter", "aCompCor"]] = ("36-parameter",), + smooth: float | None = None, runner_config: RunnerConfig | None = None, ) -> None: """Run longitudinal functional processing for all matching subjects/sessions. @@ -89,6 +92,7 @@ def run( output_dir: Output directory for derivatives. filters: Participant/session/task filters applied before grouping. regressors: Regressor strategies to apply in longitudinal space. + smooth: Smoothing kernel FWHM in mm, or ``None`` to skip smoothing. runner_config: Execution backend configuration. """ config = runner_config or RunnerConfig() @@ -110,6 +114,7 @@ def run( func_df=func_df, tpl_df=tpl_df, regressors=regressors, + smooth=smooth, ) pipe_ctx.ensure_dataset_description() diff --git a/src/rbc/workflows/longitudinal/functional.py b/src/rbc/workflows/longitudinal/functional.py index fdf7b669..1f036d17 100644 --- a/src/rbc/workflows/longitudinal/functional.py +++ b/src/rbc/workflows/longitudinal/functional.py @@ -12,6 +12,7 @@ from typing import TYPE_CHECKING, NamedTuple from rbc.core.functional import apply_regression, apply_regression_bandpass +from rbc.core.common import smooth as apply_smooth from rbc.core.longitudinal.transform import ( compose_transform, func_transform, @@ -36,6 +37,8 @@ class FunctionalLongOutputs(NamedTuple): in longitudinal template space, keyed by strategy name. cleaned_bold: Per-regressor nuisance-regressed + bandpass-filtered BOLD in longitudinal template space, keyed by strategy name. + cleaned_bold_smooth: Per-regressor spatially smoothed nuisance-regressed + + bandpass-filtered in longitudinal template space, or *None*. """ bold_to_long_xfm: Path @@ -44,12 +47,14 @@ class FunctionalLongOutputs(NamedTuple): bold_mask: Path regressed_bold: dict[str, Path] cleaned_bold: dict[str, Path] + cleaned_bold_smooth: dict[str, Path] | None def longitudinal_process( template: Path, anat_to_template_xfm: Path, *, + smooth: float | None = None, bold_to_anat_itk: Path, sbref: Path, bold: Path, @@ -70,6 +75,9 @@ def longitudinal_process( Args: template: Longitudinal template image. anat_to_template_xfm: T1w-to-longitudinal-template composite warp. + smooth: Smoothing kernel FWHM in mm, or ``None`` to skip smoothing. + Applied to cleaned BOLD after regression and bandpass filtering, + export-only. bold_to_anat_itk: BOLD-to-T1w affine in ITK format. sbref: Motion reference (single-band reference) volume. bold: Preprocessed bold image. @@ -112,7 +120,21 @@ def longitudinal_process( brain_mask_file=long_mask, regressor_file=reg_file, ).regressed_bold - + + # Optionally smooth cleaned BOLD (export-only) + cleaned_bold_smooth: dict[str, Path] | None = None + if smooth is not None: + cleaned_bold_smooth = {} + for reg in regressor_files: + _logger.info( + "Longitudinal %s smoothing cleaned BOLD (FWHM=%.1f mm)", reg, smooth + ) + cleaned_bold_smooth[reg] = apply_smooth( + cleaned[reg], + long_mask, + fwhm=smooth, + ) + return FunctionalLongOutputs( bold_to_long_xfm=bold_to_tpl_xfm, sbref=long_sbref, @@ -120,4 +142,5 @@ def longitudinal_process( bold_mask=long_mask, regressed_bold=regressed, cleaned_bold=cleaned, + cleaned_bold_smooth=cleaned_bold_smooth, ) From 361bd77fc2d86c5d487cbd8206b523e59fc81750 Mon Sep 17 00:00:00 2001 From: Janhavi Pillai Date: Fri, 17 Apr 2026 14:48:25 -0400 Subject: [PATCH 10/13] ruff & longitudinal func smoothing test --- src/rbc/bids/longitudinal/functional.py | 1 + src/rbc/cli/longitudinal/functional.py | 2 +- src/rbc/workflows/longitudinal/functional.py | 8 ++++---- tests/unit/bids/test_longitudinal_functional.py | 4 ++++ 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/rbc/bids/longitudinal/functional.py b/src/rbc/bids/longitudinal/functional.py index b202ada3..8b088ec7 100644 --- a/src/rbc/bids/longitudinal/functional.py +++ b/src/rbc/bids/longitudinal/functional.py @@ -15,6 +15,7 @@ from rbc.bids import Bids from rbc.workflows.longitudinal.functional import FunctionalLongOutputs + def _smooth_label(fwhm: float, precision: int | None = None) -> str: """Format FWHM as a BIDS-safe label (e.g. 6.0 -> 'sm6', 0.1 -> 'sm0p1'). diff --git a/src/rbc/cli/longitudinal/functional.py b/src/rbc/cli/longitudinal/functional.py index 88fc000b..19a897c8 100644 --- a/src/rbc/cli/longitudinal/functional.py +++ b/src/rbc/cli/longitudinal/functional.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Literal -from rbc.cli.base import _validate_task, _validate_positive +from rbc.cli.base import _validate_positive, _validate_task from rbc.cli.longitudinal._base import LongitudinalBaseArgs, add_fs_license_argument from rbc.orchestration import Filters, RunnerConfig from rbc.orchestration.longitudinal.functional import run diff --git a/src/rbc/workflows/longitudinal/functional.py b/src/rbc/workflows/longitudinal/functional.py index 1f036d17..2bfe48e5 100644 --- a/src/rbc/workflows/longitudinal/functional.py +++ b/src/rbc/workflows/longitudinal/functional.py @@ -11,8 +11,8 @@ import logging from typing import TYPE_CHECKING, NamedTuple -from rbc.core.functional import apply_regression, apply_regression_bandpass from rbc.core.common import smooth as apply_smooth +from rbc.core.functional import apply_regression, apply_regression_bandpass from rbc.core.longitudinal.transform import ( compose_transform, func_transform, @@ -37,7 +37,7 @@ class FunctionalLongOutputs(NamedTuple): in longitudinal template space, keyed by strategy name. cleaned_bold: Per-regressor nuisance-regressed + bandpass-filtered BOLD in longitudinal template space, keyed by strategy name. - cleaned_bold_smooth: Per-regressor spatially smoothed nuisance-regressed + cleaned_bold_smooth: Per-regressor spatially smoothed nuisance-regressed + bandpass-filtered in longitudinal template space, or *None*. """ @@ -120,7 +120,7 @@ def longitudinal_process( brain_mask_file=long_mask, regressor_file=reg_file, ).regressed_bold - + # Optionally smooth cleaned BOLD (export-only) cleaned_bold_smooth: dict[str, Path] | None = None if smooth is not None: @@ -134,7 +134,7 @@ def longitudinal_process( long_mask, fwhm=smooth, ) - + return FunctionalLongOutputs( bold_to_long_xfm=bold_to_tpl_xfm, sbref=long_sbref, diff --git a/tests/unit/bids/test_longitudinal_functional.py b/tests/unit/bids/test_longitudinal_functional.py index 98a17d41..2c7baab8 100644 --- a/tests/unit/bids/test_longitudinal_functional.py +++ b/tests/unit/bids/test_longitudinal_functional.py @@ -105,6 +105,10 @@ def _dummy(name: str) -> Path: "36-parameter": _dummy("cleaned_36p.nii.gz"), "aCompCor": _dummy("cleaned_acompcor.nii.gz"), }, + cleaned_bold_smooth={ + "36-parameter": _dummy("cleaned_36p_smooth.nii.gz"), + "aCompCor": _dummy("cleaned_acompcor_smooth.nii.gz"), + }, ) From 2c079d260c44f1f7a87cac6e98333d7788cab76e Mon Sep 17 00:00:00 2001 From: Janhavi Pillai Date: Fri, 17 Apr 2026 14:56:16 -0400 Subject: [PATCH 11/13] tests & smoothing helper --- src/rbc/bids/functional.py | 12 +----------- src/rbc/bids/longitudinal/functional.py | 12 +----------- src/rbc/bids/metrics.py | 12 +----------- tests/unit/cli/test_longitudinal.py | 1 + 4 files changed, 4 insertions(+), 33 deletions(-) diff --git a/src/rbc/bids/functional.py b/src/rbc/bids/functional.py index b0a817b7..2b0f288d 100644 --- a/src/rbc/bids/functional.py +++ b/src/rbc/bids/functional.py @@ -18,17 +18,7 @@ def _smooth_label(fwhm: float, precision: int | None = None) -> str: - """Format FWHM as a BIDS-safe label (e.g. 6.0 -> 'sm6', 0.1 -> 'sm0p1'). - - Trailing zeros are stripped and '.' is replaced with 'p' for BIDS compliance. - - Args: - fwhm: Smoothing kernel FWHM in mm. - precision: Optional number of decimal places to format to before stripping. - - Returns: - BIDS-safe label string (e.g. 'sm6', 'sm0p1'). - """ + """Format FWHM as a BIDS-safe label (e.g. 6.0 -> 'sm6', 0.1 -> 'sm0p1').""" s = f"{fwhm:.{precision}f}" if precision is not None else str(fwhm) return "sm" + s.rstrip("0").rstrip(".").replace(".", "p") diff --git a/src/rbc/bids/longitudinal/functional.py b/src/rbc/bids/longitudinal/functional.py index 8b088ec7..f32cb675 100644 --- a/src/rbc/bids/longitudinal/functional.py +++ b/src/rbc/bids/longitudinal/functional.py @@ -17,17 +17,7 @@ def _smooth_label(fwhm: float, precision: int | None = None) -> str: - """Format FWHM as a BIDS-safe label (e.g. 6.0 -> 'sm6', 0.1 -> 'sm0p1'). - - Trailing zeros are stripped and '.' is replaced with 'p' for BIDS compliance. - - Args: - fwhm: Smoothing kernel FWHM in mm. - precision: Optional number of decimal places to format to before stripping. - - Returns: - BIDS-safe label string (e.g. 'sm6', 'sm0p1'). - """ + """Format FWHM as a BIDS-safe label (e.g. 6.0 -> 'sm6', 0.1 -> 'sm0p1').""" s = f"{fwhm:.{precision}f}" if precision is not None else str(fwhm) return "sm" + s.rstrip("0").rstrip(".").replace(".", "p") diff --git a/src/rbc/bids/metrics.py b/src/rbc/bids/metrics.py index 68e5326c..10894464 100644 --- a/src/rbc/bids/metrics.py +++ b/src/rbc/bids/metrics.py @@ -17,17 +17,7 @@ def _smooth_label(fwhm: float, precision: int | None = None) -> str: - """Format FWHM as a BIDS-safe label (e.g. 6.0 -> 'sm6', 0.1 -> 'sm0p1'). - - Trailing zeros are stripped and '.' is replaced with 'p' for BIDS compliance. - - Args: - fwhm: Smoothing kernel FWHM in mm. - precision: Optional number of decimal places to format to before stripping. - - Returns: - BIDS-safe label string (e.g. 'sm6', 'sm0p1'). - """ + """Format FWHM as a BIDS-safe label (e.g. 6.0 -> 'sm6', 0.1 -> 'sm0p1').""" s = f"{fwhm:.{precision}f}" if precision is not None else str(fwhm) return "sm" + s.rstrip("0").rstrip(".").replace(".", "p") diff --git a/tests/unit/cli/test_longitudinal.py b/tests/unit/cli/test_longitudinal.py index ef2738e2..d9e8f62c 100644 --- a/tests/unit/cli/test_longitudinal.py +++ b/tests/unit/cli/test_longitudinal.py @@ -31,6 +31,7 @@ def base_ns(tmp_path: Path) -> argparse.Namespace: output_dir=tmp_path / "output", participant_label=[], session_label=[], + smooth=None, tmp_dir=None, ants_threads=1, fs_license=None, From 488a4ba7af6460a50aec86945d360cf5d7226a05 Mon Sep 17 00:00:00 2001 From: Janhavi Pillai Date: Fri, 17 Apr 2026 16:01:30 -0400 Subject: [PATCH 12/13] add smooth and expected outputs to full test --- tests/full_pipeline/longitudinal/conftest.py | 2 ++ .../full_pipeline/longitudinal/test_metrics.py | 18 ++++++++++++------ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/tests/full_pipeline/longitudinal/conftest.py b/tests/full_pipeline/longitudinal/conftest.py index c6828e5d..71bd14b5 100644 --- a/tests/full_pipeline/longitudinal/conftest.py +++ b/tests/full_pipeline/longitudinal/conftest.py @@ -201,6 +201,8 @@ def longitudinal_pipeline_data( "test", "--task", _TASK, + "--smooth", + "6", ] ) diff --git a/tests/full_pipeline/longitudinal/test_metrics.py b/tests/full_pipeline/longitudinal/test_metrics.py index 64d36f4c..c8f464c9 100644 --- a/tests/full_pipeline/longitudinal/test_metrics.py +++ b/tests/full_pipeline/longitudinal/test_metrics.py @@ -34,15 +34,21 @@ def test_longitudinal_metrics_produces_expected_files( tree = _file_tree(longitudinal_pipeline_data) expected_fragments = [ + # raw maps — always produced f"{_STEM}_space-longitudinal_reg-36parameter_alff.nii.gz", f"{_STEM}_space-longitudinal_reg-36parameter_falff.nii.gz", - f"{_STEM}_space-longitudinal_reg-36parameter_desc-smooth_alff.nii.gz", - f"{_STEM}_space-longitudinal_reg-36parameter_desc-smooth_falff.nii.gz", - f"{_STEM}_space-longitudinal_reg-36parameter_desc-smoothZstd_alff.nii.gz", - f"{_STEM}_space-longitudinal_reg-36parameter_desc-smoothZstd_falff.nii.gz", f"{_STEM}_space-longitudinal_reg-36parameter_reho.nii.gz", - f"{_STEM}_space-longitudinal_reg-36parameter_desc-smooth_reho.nii.gz", - f"{_STEM}_space-longitudinal_reg-36parameter_desc-smoothZstd_reho.nii.gz", + # z-scored raw maps - always produced + f"{_STEM}_space-longitudinal_reg-36parameter_desc-zstd_alff.nii.gz", + f"{_STEM}_space-longitudinal_reg-36parameter_desc-zstd_falff.nii.gz", + f"{_STEM}_space-longitudinal_reg-36parameter_desc-zstd_reho.nii.gz", + # smoothed + z-scored smoothed — produced with --smooth 6 + f"{_STEM}_space-longitudinal_reg-36parameter_desc-sm6_alff.nii.gz", + f"{_STEM}_space-longitudinal_reg-36parameter_desc-sm6_falff.nii.gz", + f"{_STEM}_space-longitudinal_reg-36parameter_desc-sm6_reho.nii.gz", + f"{_STEM}_space-longitudinal_reg-36parameter_desc-sm6Zstd_alff.nii.gz", + f"{_STEM}_space-longitudinal_reg-36parameter_desc-sm6Zstd_falff.nii.gz", + f"{_STEM}_space-longitudinal_reg-36parameter_desc-sm6Zstd_reho.nii.gz", ] for name in expected_fragments: assert (func / name).is_file(), ( From ee51081a512c93cb02d448d4cfd4d1493b1bec79 Mon Sep 17 00:00:00 2001 From: Janhavi Pillai Date: Fri, 17 Apr 2026 17:17:37 -0400 Subject: [PATCH 13/13] add smoothing to conftest all --- tests/full_pipeline/longitudinal/conftest.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/full_pipeline/longitudinal/conftest.py b/tests/full_pipeline/longitudinal/conftest.py index 71bd14b5..dd602eca 100644 --- a/tests/full_pipeline/longitudinal/conftest.py +++ b/tests/full_pipeline/longitudinal/conftest.py @@ -278,6 +278,8 @@ def longitudinal_all_data( "test", "--task", _TASK, + "--smooth", + "6", ] )