diff --git a/aslprep/data/io_spec.json b/aslprep/data/io_spec.json index 7ca565f41..96ba6ec3d 100644 --- a/aslprep/data/io_spec.json +++ b/aslprep/data/io_spec.json @@ -2,7 +2,7 @@ "queries": { "baseline": { "hmc": { - "datatype": "func", + "datatype": "perf", "space": null, "desc": "hmc", "suffix": "aslref", @@ -12,7 +12,7 @@ ] }, "coreg": { - "datatype": "func", + "datatype": "perf", "space": null, "desc": "coreg", "suffix": "aslref", @@ -20,6 +20,106 @@ ".nii.gz", ".nii" ] + }, + "mean_cbf": { + "datatype": "perf", + "space": null, + "desc": null, + "suffix": "cbf", + "extension": [ + ".nii.gz", + ".nii" + ] + }, + "cbf_ts": { + "datatype": "perf", + "space": null, + "desc": "timeseries", + "suffix": "cbf", + "extension": [ + ".nii.gz", + ".nii" + ] + }, + "att": { + "datatype": "perf", + "space": null, + "desc": null, + "suffix": "att", + "extension": [ + ".nii.gz", + ".nii" + ] + }, + "cbf_ts_score": { + "datatype": "perf", + "space": null, + "desc": "scoreTimeseries", + "suffix": "cbf", + "extension": [ + ".nii.gz", + ".nii" + ] + }, + "mean_cbf_score": { + "datatype": "perf", + "space": null, + "desc": "score", + "suffix": "cbf", + "extension": [ + ".nii.gz", + ".nii" + ] + }, + "mean_cbf_scrub": { + "datatype": "perf", + "space": null, + "desc": "scrub", + "suffix": "cbf", + "extension": [ + ".nii.gz", + ".nii" + ] + }, + "mean_cbf_basil": { + "datatype": "perf", + "space": null, + "desc": "basil", + "suffix": "cbf", + "extension": [ + ".nii.gz", + ".nii" + ] + }, + "mean_cbf_gm_basil": { + "datatype": "perf", + "space": null, + "desc": "basilGM", + "suffix": "cbf", + "extension": [ + ".nii.gz", + ".nii" + ] + }, + "mean_cbf_wm_basil": { + "datatype": "perf", + "space": null, + "desc": "basilWM", + "suffix": "cbf", + "extension": [ + ".nii.gz", + ".nii" + ] + }, + "att_basil": { + "datatype": "perf", + "space": null, + "desc": "basil", + "suffix": "att", + "extension": [ + ".nii.gz", + ".nii" + ] } }, "transforms": { diff --git a/aslprep/tests/data/expected_outputs_test_003_minimal.txt b/aslprep/tests/data/expected_outputs_test_003_minimal.txt index 7244e5f18..7874cfe3d 100644 --- a/aslprep/tests/data/expected_outputs_test_003_minimal.txt +++ b/aslprep/tests/data/expected_outputs_test_003_minimal.txt @@ -7,10 +7,14 @@ sub-01 sub-01.html sub-01/ses-1 sub-01/ses-1/perf +sub-01/ses-1/perf/sub-01_ses-1_cbf.json +sub-01/ses-1/perf/sub-01_ses-1_cbf.nii.gz sub-01/ses-1/perf/sub-01_ses-1_desc-coreg_aslref.json sub-01/ses-1/perf/sub-01_ses-1_desc-coreg_aslref.nii.gz sub-01/ses-1/perf/sub-01_ses-1_desc-hmc_aslref.json sub-01/ses-1/perf/sub-01_ses-1_desc-hmc_aslref.nii.gz +sub-01/ses-1/perf/sub-01_ses-1_desc-timeseries_cbf.json +sub-01/ses-1/perf/sub-01_ses-1_desc-timeseries_cbf.nii.gz sub-01/ses-1/perf/sub-01_ses-1_from-aslref_to-T1w_mode-image_xfm.json sub-01/ses-1/perf/sub-01_ses-1_from-aslref_to-T1w_mode-image_xfm.txt sub-01/ses-1/perf/sub-01_ses-1_from-orig_to-aslref_mode-image_xfm.json diff --git a/aslprep/tests/data/expected_outputs_test_003_resampling.txt b/aslprep/tests/data/expected_outputs_test_003_resampling.txt index dfa5279b8..b14675883 100644 --- a/aslprep/tests/data/expected_outputs_test_003_resampling.txt +++ b/aslprep/tests/data/expected_outputs_test_003_resampling.txt @@ -8,12 +8,16 @@ sub-01 sub-01.html sub-01/ses-1 sub-01/ses-1/perf +sub-01/ses-1/perf/sub-01_ses-1_cbf.json +sub-01/ses-1/perf/sub-01_ses-1_cbf.nii.gz sub-01/ses-1/perf/sub-01_ses-1_desc-confounds_timeseries.tsv sub-01/ses-1/perf/sub-01_ses-1_desc-coreg_aslref.json sub-01/ses-1/perf/sub-01_ses-1_desc-coreg_aslref.nii.gz sub-01/ses-1/perf/sub-01_ses-1_desc-hmc_aslref.json sub-01/ses-1/perf/sub-01_ses-1_desc-hmc_aslref.nii.gz sub-01/ses-1/perf/sub-01_ses-1_desc-qualitycontrol_cbf.tsv +sub-01/ses-1/perf/sub-01_ses-1_desc-timeseries_cbf.json +sub-01/ses-1/perf/sub-01_ses-1_desc-timeseries_cbf.nii.gz sub-01/ses-1/perf/sub-01_ses-1_from-aslref_to-T1w_mode-image_xfm.json sub-01/ses-1/perf/sub-01_ses-1_from-aslref_to-T1w_mode-image_xfm.txt sub-01/ses-1/perf/sub-01_ses-1_from-orig_to-aslref_mode-image_xfm.json diff --git a/aslprep/tests/test_cli.py b/aslprep/tests/test_cli.py index 8a489a755..ad19b4e70 100644 --- a/aslprep/tests/test_cli.py +++ b/aslprep/tests/test_cli.py @@ -334,6 +334,15 @@ def test_test_003_minimal(data_dir, output_dir, working_dir): extra_params=["--fs-no-reconall"], ) + base_test_003( + data_dir, + output_dir, + working_dir, + level="full", + level_name="minimal", + extra_params=["--fs-no-reconall"], + ) + @pytest.mark.test_003_resampling def test_test_003_resampling(data_dir, output_dir, working_dir): @@ -359,7 +368,7 @@ def test_test_003_full(data_dir, output_dir, working_dir): ) -def base_test_003(data_dir, output_dir, working_dir, level, extra_params): +def base_test_003(data_dir, output_dir, working_dir, level, level_name=None, extra_params=None): """Run aslprep on sub-01. This dataset is Siemens. @@ -369,7 +378,11 @@ def base_test_003(data_dir, output_dir, working_dir, level, extra_params): dataset_dir = download_test_data(TEST_NAME, data_dir) download_test_data("anatomical", data_dir) - level_test_name = f"{TEST_NAME}_{level}" + if level_name: + level_test_name = f"{TEST_NAME}_{level_name}" + else: + level_test_name = f"{TEST_NAME}_{level}" + out_dir = os.path.join(output_dir, level_test_name, "aslprep") work_dir = os.path.join(working_dir, level_test_name) @@ -383,6 +396,7 @@ def base_test_003(data_dir, output_dir, working_dir, level, extra_params): "--omp-nthreads=1", "--output-spaces", "asl", + "MNI152NLin2009cAsym", "--use-syn-sdc", "--m0_scale=10", f"--fs-subjects-dir={os.path.join(data_dir, 'anatomical/freesurfer')}", diff --git a/aslprep/workflows/asl/base.py b/aslprep/workflows/asl/base.py index 88e46b0df..20d1a5906 100644 --- a/aslprep/workflows/asl/base.py +++ b/aslprep/workflows/asl/base.py @@ -23,6 +23,7 @@ from aslprep.workflows.asl.fit import init_asl_fit_wf, init_asl_native_wf from aslprep.workflows.asl.outputs import ( init_ds_asl_native_wf, + init_ds_cbf_native_wf, init_ds_ciftis_wf, init_ds_volumes_wf, ) @@ -380,6 +381,7 @@ def init_asl_wf( processing_target=processing_target, dummy_scans=dummy_scans, m0_scale=m0_scale, + precomputed=precomputed, scorescrub=scorescrub, basil=basil, smooth_kernel=smooth_kernel, @@ -404,6 +406,30 @@ def init_asl_wf( ]), ]) # fmt:skip + # If we want aslref-space outputs, then call the appropriate workflow + aslref_out = bool(nonstd_spaces.intersection(("func", "run", "asl", "aslref", "sbref"))) + aslref_out &= config.workflow.level == "full" + + # Write out aslref-space CBF derivatives for the minimal and resampling workflows, + # or if we want them anyway. + if (config.workflow.level != "full") or aslref_out: + ds_cbf_native_wf = init_ds_cbf_native_wf( + bids_root=str(config.execution.bids_dir), + output_dir=config.execution.aslprep_dir, + metadata=metadata, + cbf_3d=cbf_3d_derivs, + cbf_4d=cbf_4d_derivs, + att=att_derivs, + ) + ds_cbf_native_wf.inputs.inputnode.source_files = [asl_file] + + for cbf_deriv in cbf_derivs: + workflow.connect([ + (cbf_wf, ds_cbf_native_wf, [ + (f"outputnode.{cbf_deriv}", f"inputnode.{cbf_deriv}"), + ]), + ]) # fmt:skip + if config.workflow.level == "minimal": return workflow @@ -412,7 +438,7 @@ def init_asl_wf( # - Calculate ASL confounds and CBF QC metrics. # - Generate plots for CBF. # - Resample to anatomical space. - # - Save aslref-space outputs only if requested. + # - Save aslref-space ASL outputs only if requested. # asl_confounds_wf = init_asl_confounds_wf( @@ -482,7 +508,8 @@ def init_asl_wf( ]) # fmt:skip # Plot CBF outputs. - # NOTE: CIFTI input won't be provided unless level is set to 'full'. + # NOTE: CIFTI input won't be provided unless level is set to 'full' and CIFTI outputs are + # requested. cbf_reporting_wf = init_cbf_reporting_wf( metadata=metadata, plot_timeseries=not (is_multi_pld or use_ge or (config.workflow.level == "resampling")), @@ -519,19 +546,12 @@ def init_asl_wf( (cbf_wf, cbf_reporting_wf, [(f"outputnode.{cbf_deriv}", f"inputnode.{cbf_deriv}")]), ]) # fmt:skip - # If we want aslref-space outputs, then call the appropriate workflow - aslref_out = bool(nonstd_spaces.intersection(("func", "run", "asl", "aslref", "sbref"))) - aslref_out &= config.workflow.level == "full" - if aslref_out: ds_asl_native_wf = init_ds_asl_native_wf( bids_root=str(config.execution.bids_dir), output_dir=config.execution.aslprep_dir, asl_output=aslref_out, metadata=metadata, - cbf_3d=cbf_3d_derivs, - cbf_4d=cbf_4d_derivs, - att=att_derivs, ) ds_asl_native_wf.inputs.inputnode.source_files = [asl_file] @@ -540,13 +560,6 @@ def init_asl_wf( (asl_native_wf, ds_asl_native_wf, [("outputnode.asl_native", "inputnode.asl")]), ]) # fmt:skip - for cbf_deriv in cbf_derivs: - workflow.connect([ - (cbf_wf, ds_asl_native_wf, [ - (f"outputnode.{cbf_deriv}", f"inputnode.{cbf_deriv}"), - ]), - ]) # fmt:skip - if config.workflow.level == "resampling": # Fill in datasinks of reportlets seen so far for node in workflow.list_node_names(): diff --git a/aslprep/workflows/asl/cbf.py b/aslprep/workflows/asl/cbf.py index 4468acbae..00b956a30 100644 --- a/aslprep/workflows/asl/cbf.py +++ b/aslprep/workflows/asl/cbf.py @@ -32,11 +32,39 @@ from aslprep.workflows.asl.util import init_enhance_and_skullstrip_asl_wf +def determine_expected_cbf(is_multi_pld, scorescrub, basil): + """Predict which CBF/ATT derivatives should be generated.""" + expected_cbf = ["mean_cbf"] + if is_multi_pld: + expected_cbf.append("att") + else: + expected_cbf.append("cbf_ts") + + if scorescrub: + expected_cbf += [ + "cbf_ts_score", + "mean_cbf_score", + "mean_cbf_scrub", + "score_outlier_index", + ] + + if basil: + expected_cbf += [ + "mean_cbf_basil", + "mean_cbf_gm_basil", + "mean_cbf_wm_basil", + "att_basil", + ] + + return expected_cbf + + def init_cbf_wf( name_source, processing_target, metadata, dummy_scans, + precomputed={}, scorescrub=False, basil=False, m0_scale=1, @@ -66,6 +94,7 @@ def init_cbf_wf( processing_target="control", metadata=metadata, dummy_scans=0, + precomputed={}, scorescrub=True, basil=True, ) @@ -76,6 +105,7 @@ def init_cbf_wf( Path to the raw ASL file. metadata : :obj:`dict` BIDS metadata for asl file + precomputed scorescrub basil m0_scale @@ -251,11 +281,37 @@ def init_cbf_wf( "mean_cbf_gm_basil", "mean_cbf_wm_basil", "att_basil", - ] + ], ), name="outputnode", ) + # Can contain + # 1) mean_cbf + # 2) cbf_ts + # 3) att + # 4) cbf_ts_score + # 5) mean_cbf_score + # 6) mean_cbf_scrub + # 7) score_outlier_index + # 8) mean_cbf_basil + # 9) mean_cbf_gm_basil + # 10) mean_cbf_wm_basil + # 11) att_basil + expected_cbf_derivs = determine_expected_cbf(is_multi_pld, scorescrub, basil) + cbf_buffer = pe.Node(niu.IdentityInterface(fields=expected_cbf_derivs), name="cbf_buffer") + for expected_cbf_deriv in expected_cbf_derivs: + if expected_cbf_deriv in precomputed: + cbf_buffer.set_input(expected_cbf_deriv, precomputed[expected_cbf_deriv]) + + workflow.connect([(cbf_buffer, outputnode, [(expected_cbf_deriv, expected_cbf_deriv)])]) + + if all([k in precomputed for k in expected_cbf_derivs]): + config.loggers.workflow.info( + "Found aslref-space CBF derivatives - skipping CBF calculation" + ) + return workflow + warp_t1w_mask_to_asl = pe.Node( ApplyTransforms( dimension=3, @@ -419,12 +475,12 @@ def _getfiledir(file): ("m0_file", "m0_file"), ("metadata", "metadata"), ]), - (compute_cbf, outputnode, [ + (compute_cbf, cbf_buffer, [ ("cbf_ts", "cbf_ts"), ("mean_cbf", "mean_cbf"), ("att", "att"), - ("plds", "plds"), ]), + (compute_cbf, outputnode, [("plds", "plds")]), ]) # fmt:skip if scorescrub: @@ -449,7 +505,7 @@ def _getfiledir(file): (gm_tfm, score_and_scrub_cbf, [("output_image", "gm_tpm")]), (wm_tfm, score_and_scrub_cbf, [("output_image", "wm_tpm")]), (csf_tfm, score_and_scrub_cbf, [("output_image", "csf_tpm")]), - (score_and_scrub_cbf, outputnode, [ + (score_and_scrub_cbf, cbf_buffer, [ ("cbf_ts_score", "cbf_ts_score"), ("score_outlier_index", "score_outlier_index"), ("mean_cbf_score", "mean_cbf_score"), @@ -514,7 +570,8 @@ def _getfiledir(file): basil_kwargs["slice_spacing"] = slicetime_diffs[0] else: config.loggers.interface.warning( - "Slice times are not ascending. They will be ignored in the BASIL call." + "Slice times are not monotonically ascending. " + "They will be ignored in the BASIL call." ) basilcbf = pe.Node( @@ -541,7 +598,7 @@ def _getfiledir(file): (estimate_alpha, basilcbf, [("labeling_efficiency", "alpha")]), (gm_tfm, basilcbf, [("output_image", "gm_tpm")]), (wm_tfm, basilcbf, [("output_image", "wm_tpm")]), - (basilcbf, outputnode, [ + (basilcbf, cbf_buffer, [ ("mean_cbf_basil", "mean_cbf_basil"), ("mean_cbf_gm_basil", "mean_cbf_gm_basil"), ("mean_cbf_wm_basil", "mean_cbf_wm_basil"), diff --git a/aslprep/workflows/asl/outputs.py b/aslprep/workflows/asl/outputs.py index e3557d4eb..c97695f09 100644 --- a/aslprep/workflows/asl/outputs.py +++ b/aslprep/workflows/asl/outputs.py @@ -475,25 +475,20 @@ def init_ds_aslref_wf( return workflow -def init_ds_asl_native_wf( +def init_ds_cbf_native_wf( *, bids_root: str, output_dir: str, - asl_output: bool, - metadata: ty.List[dict], + metadata: ty.List[dict], # noqa: U100 cbf_3d: ty.List[str], cbf_4d: ty.List[str], att: ty.List[str], - name="ds_asl_native_wf", + name="ds_cbf_native_wf", ) -> pe.Workflow: - """Write out aslref-space outputs.""" + """Write out aslref-space CBF outputs.""" workflow = pe.Workflow(name=name) - inputnode_fields = [ - "source_files", - "asl", - "asl_mask", - ] + inputnode_fields = ["source_files"] inputnode_fields += cbf_3d inputnode_fields += cbf_4d inputnode_fields += att @@ -506,6 +501,67 @@ def init_ds_asl_native_wf( raw_sources.inputs.bids_root = bids_root workflow.connect([(inputnode, raw_sources, [("source_files", "in_files")])]) + datasinks = [] + for cbf_deriv in cbf_4d + cbf_3d + att: + if cbf_deriv in att: + meta = {"Units": "s"} + else: + meta = {"Units": "mL/100 g/min"} + + fields = BASE_INPUT_FIELDS[cbf_deriv] + + ds_cbf = pe.Node( + DerivativesDataSink( + base_directory=output_dir, + compress=True, + dismiss_entities=("echo",), + **fields, + **meta, + ), + name=f"ds_{cbf_deriv}", + run_without_submitting=True, + mem_gb=config.DEFAULT_MEMORY_MIN_GB, + ) + workflow.connect([(inputnode, ds_cbf, [(cbf_deriv, "in_file")])]) + datasinks.append(ds_cbf) + + workflow.connect( + [ + (inputnode, datasink, [("source_files", "source_file")]) for datasink in datasinks + ] + [ + (raw_sources, datasink, [("out", "RawSources")]) for datasink in datasinks + ] + ) # fmt:skip + + return workflow + + +def init_ds_asl_native_wf( + *, + bids_root: str, + output_dir: str, + asl_output: bool, + metadata: ty.List[dict], + name="ds_asl_native_wf", +) -> pe.Workflow: + """Write out aslref-space outputs.""" + workflow = pe.Workflow(name=name) + + inputnode = pe.Node( + niu.IdentityInterface( + fields=[ + "source_files", + "asl", + "asl_mask", + ], + ), + name="inputnode", + ) + + raw_sources = pe.Node(niu.Function(function=_bids_relative), name="raw_sources") + raw_sources.inputs.bids_root = bids_root + workflow.connect([(inputnode, raw_sources, [("source_files", "in_files")])]) + # Masks should be output if any other derivatives are output ds_asl_mask = pe.Node( DerivativesDataSink( @@ -543,51 +599,6 @@ def init_ds_asl_native_wf( workflow.connect([(inputnode, ds_asl, [("asl", "in_file")])]) datasinks = [ds_asl] - for cbf_name in cbf_4d + cbf_3d: - # TODO: Add EstimationReference and EstimationAlgorithm - cbf_meta = { - "Units": "mL/100 g/min", - } - fields = BASE_INPUT_FIELDS[cbf_name] - - ds_cbf = pe.Node( - DerivativesDataSink( - base_directory=output_dir, - compress=True, - dismiss_entities=("echo",), - **fields, - **cbf_meta, - ), - name=f"ds_{cbf_name}", - run_without_submitting=True, - mem_gb=config.DEFAULT_MEMORY_MIN_GB, - ) - datasinks.append(ds_cbf) - workflow.connect([(inputnode, ds_cbf, [(cbf_name, "in_file")])]) - - for att_name in att: - # TODO: Add EstimationReference and EstimationAlgorithm - att_meta = { - "Units": "s", - } - fields = BASE_INPUT_FIELDS[att_name] - - ds_att = pe.Node( - DerivativesDataSink( - base_directory=output_dir, - compress=True, - dismiss_entities=("echo",), - **fields, - **att_meta, - ), - name=f"ds_{att_name}", - run_without_submitting=True, - mem_gb=config.DEFAULT_MEMORY_MIN_GB, - ) - datasinks.append(ds_att) - - workflow.connect([(inputnode, ds_att, [(att_name, "in_file")])]) - workflow.connect( [ (inputnode, datasink, [("source_files", "source_file")]) for datasink in datasinks