From 084b5ebaf8ad03f026ae23c72aa30a7ae00ea92c Mon Sep 17 00:00:00 2001 From: mnoergaard Date: Sat, 24 May 2025 11:46:24 +0200 Subject: [PATCH 1/7] FIX: remove dummy scans --- fmriprep/workflows/pet/fit.py | 1 - 1 file changed, 1 deletion(-) diff --git a/fmriprep/workflows/pet/fit.py b/fmriprep/workflows/pet/fit.py index 6b0c5208..721d4b1d 100644 --- a/fmriprep/workflows/pet/fit.py +++ b/fmriprep/workflows/pet/fit.py @@ -252,7 +252,6 @@ def init_pet_fit_wf( pet_file=pet_file, reference_frame=config.workflow.reference_frame, ) - petref_wf.inputs.inputnode.dummy_scans = config.workflow.dummy_scans ds_petref_wf = init_ds_petref_wf( bids_root=layout.root, From c8ba5d8ad2a36411cb7f042ed5340cd02398dd20 Mon Sep 17 00:00:00 2001 From: mnoergaard Date: Sat, 24 May 2025 12:46:24 +0200 Subject: [PATCH 2/7] FIX: return workflow from reference and add check in fit.py --- fmriprep/workflows/pet/fit.py | 9 +++++++++ fmriprep/workflows/pet/reference.py | 2 ++ 2 files changed, 11 insertions(+) diff --git a/fmriprep/workflows/pet/fit.py b/fmriprep/workflows/pet/fit.py index 721d4b1d..a339c083 100644 --- a/fmriprep/workflows/pet/fit.py +++ b/fmriprep/workflows/pet/fit.py @@ -261,6 +261,15 @@ def init_pet_fit_wf( ) ds_petref_wf.inputs.inputnode.source_files = [pet_file] + # Ensure all stage-1 workflows were created successfully before + # attempting to connect them. Nipype's ``connect`` call will fail + # with a ``NoneType`` error if any node is undefined. + stage1_nodes = [petref_wf, petref_buffer, ds_petref_wf, + func_fit_reports_wf, petref_source_buffer] + if any(node is None for node in stage1_nodes): + raise RuntimeError('PET reference stage could not be built - ' + 'check inputs and configuration.') + workflow.connect([ (petref_wf, petref_buffer, [ ('outputnode.pet_file', 'pet_file'), diff --git a/fmriprep/workflows/pet/reference.py b/fmriprep/workflows/pet/reference.py index 0e84499d..38d35d3f 100644 --- a/fmriprep/workflows/pet/reference.py +++ b/fmriprep/workflows/pet/reference.py @@ -137,6 +137,8 @@ def init_raw_petref_wf( ] ) # fmt:skip + return workflow + def init_validation_and_dummies_wf( pet_file=None, From 31e3adfb30f38b4208ffaf27295c9d106b4d3791 Mon Sep 17 00:00:00 2001 From: mnoergaard Date: Sat, 24 May 2025 13:54:47 +0200 Subject: [PATCH 3/7] FIX: estimate masks --- fmriprep/workflows/base.py | 8 ++++---- fmriprep/workflows/pet/confounds.py | 27 ++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/fmriprep/workflows/base.py b/fmriprep/workflows/base.py index fa1b5d40..150ca8b5 100644 --- a/fmriprep/workflows/base.py +++ b/fmriprep/workflows/base.py @@ -146,7 +146,7 @@ def init_single_subject_wf(subject_id: str): """ from niworkflows.engine.workflows import LiterateWorkflow as Workflow - from niworkflows.interfaces.bids import BIDSDataGrabber, BIDSInfo + from niworkflows.interfaces.bids import BIDSInfo, BIDSDataGrabber from niworkflows.interfaces.nilearn import NILEARN_VERSION from niworkflows.interfaces.utility import KeySelect from niworkflows.utils.bids import collect_data @@ -209,7 +209,7 @@ def init_single_subject_wf(subject_id: str): config.execution.bids_dir, subject_id, bids_filters=config.execution.bids_filters, - queries=queries + queries=queries, )[0] @@ -455,12 +455,12 @@ def init_single_subject_wf(subject_id: str): ) ds_grayord_metrics_wf = init_ds_grayord_metrics_wf( bids_root=bids_root, - output_dir=fmriprep_dir, + output_dir=petprep_dir, metrics=['curv', 'thickness', 'sulc'], cifti_output=config.workflow.cifti_output, ) ds_fsLR_surfaces_wf = init_ds_surfaces_wf( - output_dir=fmriprep_dir, + output_dir=petprep_dir, surfaces=['white', 'pial', 'midthickness'], entities={ 'space': 'fsLR', diff --git a/fmriprep/workflows/pet/confounds.py b/fmriprep/workflows/pet/confounds.py index db0e0542..24dff059 100644 --- a/fmriprep/workflows/pet/confounds.py +++ b/fmriprep/workflows/pet/confounds.py @@ -262,6 +262,18 @@ def init_pet_confs_wf( 'white_matter', 'csf_wm', ] + get_pet_zooms = pe.Node(niu.Function(function=_get_zooms), name='get_pet_zooms') + acompcor_masks = pe.Node(aCompCorMasks(), name='acompcor_masks') + acompcor_tfm = pe.MapNode( + ApplyTransforms(interpolation='MultiLabel', invert_transform_flags=[True]), + name='acompcor_tfm', + iterfield=['input_image'], + ) + acompcor_bin = pe.MapNode( + Binarize(thresh_low=0.99), + name='acompcor_bin', + iterfield=['in_file'], + ) merge_rois = pe.Node( niu.Merge(3, ravel_inputs=True), name='merge_rois', run_without_submitting=True ) @@ -372,7 +384,20 @@ def _select_cols(table): (subtract_mask, outputnode, [('out_mask', 'crown_mask')]), # Global signals extraction (constrained by anatomy) (inputnode, signals, [('pet', 'in_file')]), - (inputnode, merge_rois, [('pet_mask', 'in1')]), + (inputnode, get_pet_zooms, [('pet', 'in_file')]), + (inputnode, acompcor_masks, [('t1w_tpms', 'in_vfs')]), + (get_pet_zooms, acompcor_masks, [('out', 'pet_zooms')]), + (acompcor_masks, acompcor_tfm, [('out_masks', 'input_image')]), + (inputnode, acompcor_tfm, [ + ('pet_mask', 'reference_image'), + ('petref2anat_xfm', 'transforms'), + ]), + (acompcor_tfm, acompcor_bin, [('output_image', 'in_file')]), + (acompcor_bin, merge_rois, [ + (('out_mask', _last), 'in3'), + (('out_mask', lambda l: l[0]), 'in1'), + (('out_mask', lambda l: l[1]), 'in2'), + ]), (merge_rois, signals, [('out', 'label_files')]), # Collate computed confounds together From d17cdf733ef4e3fe5ca7b1fd6af7f6e61e80e17c Mon Sep 17 00:00:00 2001 From: mnoergaard Date: Sat, 24 May 2025 14:15:12 +0200 Subject: [PATCH 4/7] ENH: add test for PET confounds workflow --- .../workflows/pet/tests/test_confounds.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 fmriprep/workflows/pet/tests/test_confounds.py diff --git a/fmriprep/workflows/pet/tests/test_confounds.py b/fmriprep/workflows/pet/tests/test_confounds.py new file mode 100644 index 00000000..efabeec9 --- /dev/null +++ b/fmriprep/workflows/pet/tests/test_confounds.py @@ -0,0 +1,34 @@ +import nibabel as nb +import numpy as np + +from ..confounds import init_pet_confs_wf + + +def test_dvars_connects_pet_mask(tmp_path): + """Check dvars node connection and execution.""" + wf = init_pet_confs_wf( + mem_gb=0.01, + metadata={}, + regressors_all_comps=False, + regressors_dvars_th=1.5, + regressors_fd_th=0.5, + ) + + edge = wf._graph.get_edge_data(wf.get_node('inputnode'), wf.get_node('dvars')) + assert ('pet_mask', 'in_mask') in edge['connect'] + + img = nb.Nifti1Image(np.random.rand(2, 2, 2, 5), np.eye(4)) + mask = nb.Nifti1Image(np.ones((2, 2, 2), dtype=np.uint8), np.eye(4)) + pet_file = tmp_path / 'pet.nii.gz' + mask_file = tmp_path / 'mask.nii.gz' + img.to_filename(pet_file) + mask.to_filename(mask_file) + + node = wf.get_node('dvars') + node.base_dir = tmp_path + node.inputs.in_file = str(pet_file) + node.inputs.in_mask = str(mask_file) + result = node.run() + + assert result.outputs.out_nstd + assert result.outputs.out_std \ No newline at end of file From 33732800cc8089d7c1e579524ca8fd7a23f89e92 Mon Sep 17 00:00:00 2001 From: mnoergaard Date: Sat, 24 May 2025 14:55:37 +0200 Subject: [PATCH 5/7] ENH: add PET mask generation workflow --- fmriprep/workflows/pet/fit.py | 38 +++++++++++++++++++ fmriprep/workflows/pet/tests/test_pet_mask.py | 29 ++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 fmriprep/workflows/pet/tests/test_pet_mask.py diff --git a/fmriprep/workflows/pet/fit.py b/fmriprep/workflows/pet/fit.py index a339c083..d64c2d77 100644 --- a/fmriprep/workflows/pet/fit.py +++ b/fmriprep/workflows/pet/fit.py @@ -37,6 +37,7 @@ from .outputs import ( init_ds_hmc_wf, init_ds_petref_wf, + init_ds_petmask_wf, init_ds_registration_wf, init_func_fit_reports_wf, prepare_timing_parameters, @@ -352,6 +353,43 @@ def init_pet_fit_wf( else: outputnode.inputs.petref2anat_xfm = petref2anat_xform + # Stage 4: Estimate PET brain mask + from niworkflows.interfaces.fixes import FixHeaderApplyTransforms as ApplyTransforms + from niworkflows.interfaces.nibabel import Binarize + + from .confounds import _binary_union + + t1w_mask_tfm = pe.Node( + ApplyTransforms(interpolation='MultiLabel', invert_transform_flags=[True]), + name='t1w_mask_tfm', + ) + petref_mask = pe.Node(Binarize(thresh_low=0.2), name='petref_mask') + merge_mask = pe.Node(niu.Function(function=_binary_union), name='merge_mask') + + if not petref2anat_xform: + workflow.connect( + [(pet_reg_wf, t1w_mask_tfm, [('outputnode.itk_pet_to_t1', 'transforms')])] + ) + else: + t1w_mask_tfm.inputs.transforms = petref2anat_xform + + workflow.connect([ + (inputnode, t1w_mask_tfm, [('t1w_mask', 'input_image')]), + (petref_buffer, t1w_mask_tfm, [('petref', 'reference_image')]), + (petref_buffer, petref_mask, [('petref', 'in_file')]), + (petref_mask, merge_mask, [('out_mask', 'mask1')]), + (t1w_mask_tfm, merge_mask, [('output_image', 'mask2')]), + (merge_mask, petref_buffer, [('out', 'pet_mask')]), + ]) + + ds_petmask_wf = init_ds_petmask_wf( + output_dir=config.execution.petprep_dir, + desc='brain', + name='ds_petmask_wf', + ) + ds_petmask_wf.inputs.inputnode.source_files = [pet_file] + workflow.connect([(merge_mask, ds_petmask_wf, [('out', 'inputnode.petmask')])]) + return workflow diff --git a/fmriprep/workflows/pet/tests/test_pet_mask.py b/fmriprep/workflows/pet/tests/test_pet_mask.py new file mode 100644 index 00000000..b135f2c8 --- /dev/null +++ b/fmriprep/workflows/pet/tests/test_pet_mask.py @@ -0,0 +1,29 @@ +import nibabel as nb +import numpy as np +from niworkflows.utils.testing import generate_bids_skeleton + +from ...tests import mock_config +from ...tests.test_base import BASE_LAYOUT +from ..base import init_pet_wf + + +def test_pet_mask_flow(tmp_path): + bids_dir = tmp_path / 'bids' + generate_bids_skeleton(bids_dir, BASE_LAYOUT) + img = nb.Nifti1Image(np.zeros((2, 2, 2, 10)), np.eye(4)) + pet_file = bids_dir / 'sub-01' / 'pet' / 'sub-01_task-rest_run-1_pet.nii.gz' + img.to_filename(pet_file) + + with mock_config(bids_dir=bids_dir): + wf = init_pet_wf(pet_series=str(pet_file), precomputed={}) + + edge = wf._graph.get_edge_data( + wf.get_node('pet_fit_wf'), wf.get_node('pet_confounds_wf') + ) + assert ('pet_mask', 'inputnode.pet_mask') in edge['connect'] + + conf_wf = wf.get_node('pet_confounds_wf') + conf_edge = conf_wf._graph.get_edge_data( + conf_wf.get_node('inputnode'), conf_wf.get_node('dvars') + ) + assert ('pet_mask', 'in_mask') in conf_edge['connect'] \ No newline at end of file From 52bf8bdbb4b03e5f6420074c3f8d4a6790bc0e21 Mon Sep 17 00:00:00 2001 From: mnoergaard Date: Sat, 24 May 2025 15:20:40 +0200 Subject: [PATCH 6/7] FIX: update pet_mask in workflow --- fmriprep/workflows/pet/fit.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fmriprep/workflows/pet/fit.py b/fmriprep/workflows/pet/fit.py index d64c2d77..c5a16642 100644 --- a/fmriprep/workflows/pet/fit.py +++ b/fmriprep/workflows/pet/fit.py @@ -177,7 +177,7 @@ def init_pet_fit_wf( workflow.add_nodes([inputnode]) petref_buffer = pe.Node( - niu.IdentityInterface(fields=['petref', 'pet_file', 'pet_mask']), + niu.IdentityInterface(fields=['petref', 'pet_file']), name='petref_buffer', ) hmc_buffer = pe.Node(niu.IdentityInterface(fields=['hmc_xforms']), name='hmc_buffer') @@ -219,8 +219,8 @@ def init_pet_fit_wf( workflow.connect([ (petref_buffer, outputnode, [ ('petref', 'petref'), - ('pet_mask', 'pet_mask'), ]), + (merge_mask, outputnode, [('out', 'pet_mask')]), (hmc_buffer, outputnode, [ ('hmc_xforms', 'motion_xfm'), ]), From 4f8d8ef7e37faf2365b040d1cfa091aaac204409 Mon Sep 17 00:00:00 2001 From: mnoergaard Date: Sat, 24 May 2025 15:42:35 +0200 Subject: [PATCH 7/7] FIX: update pet mask workflow --- fmriprep/workflows/pet/confounds.py | 7 ++- fmriprep/workflows/pet/fit.py | 48 +++++++++---------- fmriprep/workflows/pet/tests/test_fit.py | 20 ++++++++ .../workflows/pet/tests/test_reference.py | 14 +++--- 4 files changed, 53 insertions(+), 36 deletions(-) diff --git a/fmriprep/workflows/pet/confounds.py b/fmriprep/workflows/pet/confounds.py index 24dff059..d07651f4 100644 --- a/fmriprep/workflows/pet/confounds.py +++ b/fmriprep/workflows/pet/confounds.py @@ -36,7 +36,6 @@ from ...config import DEFAULT_MEMORY_MIN_GB from ...interfaces import DerivativesDataSink from ...interfaces.confounds import ( - FilterDropped, PETSummary, FramewiseDisplacement, FSLMotionParams, @@ -148,7 +147,7 @@ def init_pet_confs_wf( from niworkflows.interfaces.fixes import FixHeaderApplyTransforms as ApplyTransforms from niworkflows.interfaces.images import SignalExtraction from niworkflows.interfaces.morphology import BinaryDilation, BinarySubtraction - from niworkflows.interfaces.nibabel import ApplyMask, Binarize + from niworkflows.interfaces.nibabel import Binarize from niworkflows.interfaces.utility import AddTSVHeader, DictMerge from ...interfaces.confounds import aCompCorMasks @@ -395,8 +394,8 @@ def _select_cols(table): (acompcor_tfm, acompcor_bin, [('output_image', 'in_file')]), (acompcor_bin, merge_rois, [ (('out_mask', _last), 'in3'), - (('out_mask', lambda l: l[0]), 'in1'), - (('out_mask', lambda l: l[1]), 'in2'), + (('out_mask', lambda masks: masks[0]), 'in1'), + (('out_mask', lambda masks: masks[1]), 'in2'), ]), (merge_rois, signals, [('out', 'label_files')]), diff --git a/fmriprep/workflows/pet/fit.py b/fmriprep/workflows/pet/fit.py index c5a16642..ce604f7e 100644 --- a/fmriprep/workflows/pet/fit.py +++ b/fmriprep/workflows/pet/fit.py @@ -198,11 +198,7 @@ def init_pet_fit_wf( summary = pe.Node( FunctionalSummary( - registration=( - 'Precomputed' - if petref2anat_xform - else 'mri_coreg' - ), + registration=('Precomputed' if petref2anat_xform else 'mri_coreg'), registration_dof=config.workflow.pet2anat_dof, orientation=orientation, ), @@ -220,7 +216,6 @@ def init_pet_fit_wf( (petref_buffer, outputnode, [ ('petref', 'petref'), ]), - (merge_mask, outputnode, [('out', 'pet_mask')]), (hmc_buffer, outputnode, [ ('hmc_xforms', 'motion_xfm'), ]), @@ -265,11 +260,17 @@ def init_pet_fit_wf( # Ensure all stage-1 workflows were created successfully before # attempting to connect them. Nipype's ``connect`` call will fail # with a ``NoneType`` error if any node is undefined. - stage1_nodes = [petref_wf, petref_buffer, ds_petref_wf, - func_fit_reports_wf, petref_source_buffer] + stage1_nodes = [ + petref_wf, + petref_buffer, + ds_petref_wf, + func_fit_reports_wf, + petref_source_buffer, + ] if any(node is None for node in stage1_nodes): - raise RuntimeError('PET reference stage could not be built - ' - 'check inputs and configuration.') + raise RuntimeError( + 'PET reference stage could not be built - check inputs and configuration.' + ) workflow.connect([ (petref_wf, petref_buffer, [ @@ -373,14 +374,16 @@ def init_pet_fit_wf( else: t1w_mask_tfm.inputs.transforms = petref2anat_xform - workflow.connect([ - (inputnode, t1w_mask_tfm, [('t1w_mask', 'input_image')]), - (petref_buffer, t1w_mask_tfm, [('petref', 'reference_image')]), - (petref_buffer, petref_mask, [('petref', 'in_file')]), - (petref_mask, merge_mask, [('out_mask', 'mask1')]), - (t1w_mask_tfm, merge_mask, [('output_image', 'mask2')]), - (merge_mask, petref_buffer, [('out', 'pet_mask')]), - ]) + workflow.connect( + [ + (inputnode, t1w_mask_tfm, [('t1w_mask', 'input_image')]), + (petref_buffer, t1w_mask_tfm, [('petref', 'reference_image')]), + (petref_buffer, petref_mask, [('petref', 'in_file')]), + (petref_mask, merge_mask, [('out_mask', 'mask1')]), + (t1w_mask_tfm, merge_mask, [('output_image', 'mask2')]), + (merge_mask, outputnode, [('out', 'pet_mask')]), + ] + ) ds_petmask_wf = init_ds_petmask_wf( output_dir=config.execution.petprep_dir, @@ -482,18 +485,13 @@ def init_pet_native_wf( ) outputnode.inputs.metadata = metadata - petbuffer = pe.Node( - niu.IdentityInterface(fields=['pet_file']), name='petbuffer' - ) + petbuffer = pe.Node(niu.IdentityInterface(fields=['pet_file']), name='petbuffer') # PET source: track original PET file(s) # The Select interface requires an index to choose from ``inlist``. Since # ``pet_file`` is a single path, explicitly set the index to ``0`` to avoid # missing mandatory input errors when the node runs. - pet_source = pe.Node( - niu.Select(inlist=[pet_file], index=0), - name='pet_source' - ) + pet_source = pe.Node(niu.Select(inlist=[pet_file], index=0), name='pet_source') validate_pet = pe.Node(ValidateImage(), name='validate_pet') workflow.connect([ (pet_source, validate_pet, [('out', 'in_file')]), diff --git a/fmriprep/workflows/pet/tests/test_fit.py b/fmriprep/workflows/pet/tests/test_fit.py index 566fad7b..c19de56c 100644 --- a/fmriprep/workflows/pet/tests/test_fit.py +++ b/fmriprep/workflows/pet/tests/test_fit.py @@ -134,3 +134,23 @@ def test_pet_native_precomputes( flatgraph = wf._create_flat_graph() generate_expanded_graph(flatgraph) + + +def test_pet_fit_mask_connections(bids_root: Path, tmp_path: Path): + """Ensure the PET mask is generated and connected correctly.""" + pet_file = str(bids_root / 'sub-01' / 'pet' / 'sub-01_task-rest_run-1_pet.nii.gz') + img = nb.Nifti1Image(np.zeros((2, 2, 2, 1)), np.eye(4)) + img.to_filename(pet_file) + + with mock_config(bids_dir=bids_root): + wf = init_pet_fit_wf(pet_file=pet_file, precomputed={}, omp_nthreads=1) + + assert 'merge_mask' in wf.list_node_names() + assert 'ds_petmask_wf.ds_petmask' in wf.list_node_names() + + merge_mask = wf.get_node('merge_mask') + edge = wf._graph.get_edge_data(merge_mask, wf.get_node('outputnode')) + assert ('out', 'pet_mask') in edge['connect'] + + ds_edge = wf._graph.get_edge_data(merge_mask, wf.get_node('ds_petmask_wf')) + assert ('out', 'inputnode.petmask') in ds_edge['connect'] diff --git a/fmriprep/workflows/pet/tests/test_reference.py b/fmriprep/workflows/pet/tests/test_reference.py index 5718e36d..66c45784 100644 --- a/fmriprep/workflows/pet/tests/test_reference.py +++ b/fmriprep/workflows/pet/tests/test_reference.py @@ -6,22 +6,22 @@ def test_reference_frame_select(tmp_path): img = nb.Nifti1Image(np.zeros((5, 5, 5, 4)), np.eye(4)) - pet_file = tmp_path / "pet.nii.gz" + pet_file = tmp_path / 'pet.nii.gz' img.to_filename(pet_file) wf = init_raw_petref_wf(pet_file=str(pet_file), reference_frame=2) node_names = [n.name for n in wf._get_all_nodes()] - assert "extract_frame" in node_names - assert "gen_avg" not in node_names - node = wf.get_node("extract_frame") + assert 'extract_frame' in node_names + assert 'gen_avg' not in node_names + node = wf.get_node('extract_frame') assert node.interface.inputs.t_min == 2 def test_reference_frame_average(tmp_path): img = nb.Nifti1Image(np.zeros((5, 5, 5, 4)), np.eye(4)) - pet_file = tmp_path / "pet.nii.gz" + pet_file = tmp_path / 'pet.nii.gz' img.to_filename(pet_file) - wf = init_raw_petref_wf(pet_file=str(pet_file), reference_frame="average") + wf = init_raw_petref_wf(pet_file=str(pet_file), reference_frame='average') node_names = [n.name for n in wf._get_all_nodes()] - assert "gen_avg" in node_names \ No newline at end of file + assert 'gen_avg' in node_names \ No newline at end of file