Skip to content

Commit fde7069

Browse files
authored
Merge pull request #6 from mnoergaard/main
refactor fMRIPrep to PETPrep Still need modifications to niworkflows such as the BIDSDataGrabber and also the nipreps.json file.
2 parents 8dd1cd6 + bac3cc9 commit fde7069

File tree

11 files changed

+210
-33
lines changed

11 files changed

+210
-33
lines changed

fmriprep/cli/workflow.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ def build_workflow(config_file, retval):
8686
if config.execution.reports_only:
8787
build_log.log(25, 'Running --reports-only on participants %s', ', '.join(subject_list))
8888
session_list = (
89-
config.execution.bids_filters.get('bold', {}).get('session')
89+
config.execution.bids_filters.get('pet', config.execution.bids_filters.get('bold', {})).get('session')
9090
if config.execution.bids_filters
9191
else None
9292
)

fmriprep/data/reports-spec-pet.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package: fmriprep
2+
title: PET report for participant '{subject}', session '{session}' - fMRIPrep
3+
sections:
4+
- name: PET
5+
ordering: session
6+
reportlets:
7+
- bids: {datatype: figures, desc: summary, suffix: pet}
8+
- bids: {datatype: figures, desc: validation, suffix: pet}
9+
- bids: {datatype: figures, desc: carpetplot, suffix: pet}
10+
- bids: {datatype: figures, desc: confoundcorr, suffix: pet}
11+
- bids: {datatype: figures, desc: coreg, suffix: pet}
12+
- name: About
13+
reportlets:
14+
- bids: {datatype: figures, desc: about, suffix: T1w}

fmriprep/data/reports-spec.yml

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
package: fmriprep
2-
title: Visual report for participant '{subject}' - fMRIPrep
2+
title: Visual report for participant '{subject}' - PETPrep
33
sections:
44
- name: Summary
55
reportlets:
@@ -106,6 +106,38 @@ sections:
106106
effects and can inform decisions about feature orthogonalization prior to
107107
confound regression.
108108
subtitle: Correlations among nuisance regressors
109+
- name: PET
110+
ordering: session,task,acquisition,ceagent,reconstruction,direction,run
111+
reportlets:
112+
- bids: {datatype: figures, desc: summary, suffix: pet}
113+
caption: Summary of PET data acquisition parameters and processing workflow overview, including details such as injected dose, radiotracer used, and scan duration.
114+
static: true
115+
subtitle: PET Acquisition and Workflow Summary
116+
117+
- bids: {datatype: figures, desc: validation, suffix: pet}
118+
caption: Validation of PET images against BIDS specifications and initial quality assessment including checks for missing slices, artifacts, and alignment issues.
119+
static: true
120+
subtitle: PET Data Validation
121+
122+
- bids: {datatype: figures, desc: carpetplot, suffix: pet}
123+
caption: |
124+
Summary statistics and global PET signal measures are presented.
125+
A carpet plot displays voxel-level PET tracer uptake over time within the brain mask. Global signals calculated across the whole-brain (GS), white matter (WM), and cerebrospinal fluid (CSF) regions are plotted, along with DVARS and framewise displacement (FD) to visualize potential motion or acquisition artifacts.
126+
"Ctx" = cortex, "Cb" = cerebellum, "WM" = white matter, "CSF" = cerebrospinal fluid.
127+
static: false
128+
subtitle: PET Summary and Carpet Plot
129+
130+
- bids: {datatype: figures, desc: confoundcorr, suffix: pet}
131+
caption: |
132+
Left: Correlation heatmap illustrating relationships among PET-derived confound variables (e.g., motion parameters, global signal).
133+
Right: Magnitude of correlation between each PET confound time series and the global PET signal. High correlations suggest potential partial volume effects or motion-induced artifacts, informing subsequent confound regression strategies.
134+
static: false
135+
subtitle: PET Confound Correlation
136+
137+
- bids: {datatype: figures, desc: coreg, suffix: pet}
138+
caption: PET to anatomical alignment check
139+
static: false
140+
subtitle: Additional PET Visualizations
109141
- name: About
110142
nested: true
111143
reportlets:

fmriprep/reports/core.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ def generate_reports(
119119
# we separate the functional reports per session
120120
if session_list is None:
121121
all_filters = config.execution.bids_filters or {}
122-
filters = all_filters.get('bold', {})
122+
filters = all_filters.get("pet", all_filters.get("bold", {}))
123123
session_list = config.execution.layout.get_sessions(
124124
subject=subject_label, **filters
125125
)
@@ -145,4 +145,21 @@ def generate_reports(
145145
if report_error is not None:
146146
errors.append(report_error)
147147

148+
bootstrap_file = data.load('reports-spec-pet.yml')
149+
html_report = f'sub-{subject_label}_ses-{session_label}_pet.html'
150+
151+
report_error = run_reports(
152+
output_dir,
153+
subject_label,
154+
run_uuid,
155+
bootstrap_file=bootstrap_file,
156+
out_filename=html_report,
157+
reportlets_dir=reportlets_dir,
158+
errorname=f'report-{run_uuid}-{subject_label}-pet.err',
159+
subject=subject_label,
160+
session=session_label,
161+
)
162+
if report_error is not None:
163+
errors.append(report_error)
164+
148165
return errors

fmriprep/reports/tests/test_reports.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@
2323
'sub-001_ses-003_func.html',
2424
'sub-001_ses-004_func.html',
2525
'sub-001_ses-005_func.html',
26+
'sub-001_ses-001_pet.html',
27+
'sub-001_ses-003_pet.html',
28+
'sub-001_ses-004_pet.html',
29+
'sub-001_ses-005_pet.html',
2630
],
2731
),
2832
(4, ['sub-001.html']),
@@ -109,3 +113,30 @@ def mock_session_list(*args, **kwargs):
109113
assert 'One or more execution steps failed' in html_content, (
110114
f'The file {expected_files[0]} did not contain the reported error.'
111115
)
116+
117+
118+
def test_pet_report(tmp_path, monkeypatch):
119+
fake_uuid = 'fake_uuid'
120+
121+
pet_source = data_dir / 'work/reportlets/fmriprep'
122+
sub_dir = tmp_path / 'sub-01' / 'figures'
123+
sub_dir.mkdir(parents=True)
124+
125+
shutil.copy2(pet_source / 'sub-001/figures/sub-001_desc-about_T1w.html', sub_dir / 'sub-01_desc-about_T1w.html')
126+
shutil.copy2(pet_source / 'sub-001/figures/sub-001_ses-001_task-qct_dir-LR_part-mag_desc-summary_bold.html', sub_dir / 'sub-01_ses-baseline_desc-summary_pet.html')
127+
shutil.copy2(pet_source / 'sub-001/figures/sub-001_ses-001_task-qct_dir-LR_part-mag_desc-validation_bold.html', sub_dir / 'sub-01_ses-baseline_desc-validation_pet.html')
128+
shutil.copy2(pet_source / 'sub-001/figures/sub-001_ses-001_task-qct_dir-LR_part-mag_desc-carpetplot_bold.svg', sub_dir / 'sub-01_ses-baseline_desc-carpetplot_pet.svg')
129+
shutil.copy2(pet_source / 'sub-001/figures/sub-001_ses-001_task-qct_dir-LR_part-mag_desc-confoundcorr_bold.svg', sub_dir / 'sub-01_ses-baseline_desc-confoundcorr_pet.svg')
130+
shutil.copy2(pet_source / 'sub-01/func/sub-01_task-mixedgamblestask_run-01_bold_bbr.svg', sub_dir / 'sub-01_ses-baseline_pet.svg')
131+
132+
config.execution.aggr_ses_reports = 4
133+
config.execution.layout = BIDSLayout(data_dir / 'pet')
134+
monkeypatch.setattr(config.execution, 'bids_filters', {'pet': {'session': ['baseline']}})
135+
136+
failed_reports = generate_reports(['01'], tmp_path, fake_uuid)
137+
138+
assert not failed_reports
139+
html_file = tmp_path / 'sub-01.html'
140+
assert html_file.is_file()
141+
html_content = html_file.read_text()
142+
assert '<div id="PET"' in html_content

fmriprep/workflows/pet/base.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ def init_pet_wf(
152152
precomputed = {}
153153
pet_file = pet_series
154154

155-
fmriprep_dir = config.execution.petprep_dir
155+
petprep_dir = config.execution.petprep_dir
156156
omp_nthreads = config.nipype.omp_nthreads
157157
all_metadata = [config.execution.layout.get_metadata(file) for file in pet_series]
158158

@@ -272,7 +272,7 @@ def init_pet_wf(
272272
if petref_out:
273273
ds_pet_native_wf = init_ds_pet_native_wf(
274274
bids_root=str(config.execution.bids_dir),
275-
output_dir=fmriprep_dir,
275+
output_dir=petprep_dir,
276276
pet_output=petref_out,
277277
all_metadata=all_metadata,
278278
)
@@ -288,7 +288,7 @@ def init_pet_wf(
288288
# Fill-in datasinks of reportlets seen so far
289289
for node in workflow.list_node_names():
290290
if node.split('.')[-1].startswith('ds_report'):
291-
workflow.get_node(node).inputs.base_directory = fmriprep_dir
291+
workflow.get_node(node).inputs.base_directory = petprep_dir
292292
workflow.get_node(node).inputs.source_file = pet_file
293293
return workflow
294294

@@ -319,7 +319,7 @@ def init_pet_wf(
319319
if nonstd_spaces.intersection(('anat', 'T1w')):
320320
ds_pet_t1_wf = init_ds_volumes_wf(
321321
bids_root=str(config.execution.bids_dir),
322-
output_dir=fmriprep_dir,
322+
output_dir=petprep_dir,
323323
metadata=all_metadata[0],
324324
name='ds_pet_t1_wf',
325325
)
@@ -350,7 +350,7 @@ def init_pet_wf(
350350
)
351351
ds_pet_std_wf = init_ds_volumes_wf(
352352
bids_root=str(config.execution.bids_dir),
353-
output_dir=fmriprep_dir,
353+
output_dir=petprep_dir,
354354
metadata=all_metadata[0],
355355
name='ds_pet_std_wf',
356356
)
@@ -401,7 +401,7 @@ def init_pet_wf(
401401
surface_spaces=freesurfer_spaces,
402402
medial_surface_nan=config.workflow.medial_surface_nan,
403403
metadata=all_metadata[0],
404-
output_dir=fmriprep_dir,
404+
output_dir=petprep_dir,
405405
name='pet_surf_wf',
406406
)
407407
pet_surf_wf.inputs.inputnode.source_file = pet_file
@@ -459,7 +459,7 @@ def init_pet_wf(
459459

460460
ds_pet_cifti = pe.Node(
461461
DerivativesDataSink(
462-
base_directory=fmriprep_dir,
462+
base_directory=petprep_dir,
463463
space='fsLR',
464464
density=config.workflow.cifti_output,
465465
suffix='pet',
@@ -523,7 +523,7 @@ def init_pet_wf(
523523

524524
ds_confounds = pe.Node(
525525
DerivativesDataSink(
526-
base_directory=fmriprep_dir,
526+
base_directory=petprep_dir,
527527
desc='confounds',
528528
suffix='timeseries',
529529
),
@@ -589,7 +589,7 @@ def _last(inlist):
589589
# Fill-in datasinks of reportlets seen so far
590590
for node in workflow.list_node_names():
591591
if node.split('.')[-1].startswith('ds_report'):
592-
workflow.get_node(node).inputs.base_directory = fmriprep_dir
592+
workflow.get_node(node).inputs.base_directory = petprep_dir
593593
workflow.get_node(node).inputs.source_file = pet_file
594594

595595
return workflow

fmriprep/workflows/pet/confounds.py

Lines changed: 61 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,7 @@ def init_pet_confs_wf(
274274
iterfield=['in_file'],
275275
)
276276
merge_rois = pe.Node(
277-
niu.Merge(3, ravel_inputs=True), name='merge_rois', run_without_submitting=True
277+
niu.Merge(4, ravel_inputs=True), name='merge_rois', run_without_submitting=True
278278
)
279279
signals = pe.Node(
280280
SignalExtraction(class_labels=signals_class_labels), name='signals', mem_gb=mem_gb
@@ -350,18 +350,6 @@ def init_pet_confs_wf(
350350
mem_gb=DEFAULT_MEMORY_MIN_GB,
351351
)
352352

353-
def _last(inlist):
354-
return inlist[-1]
355-
356-
def _select_cols(table):
357-
import pandas as pd
358-
359-
return [
360-
col
361-
for col in pd.read_table(table, nrows=2).columns
362-
if not col.startswith(('a_comp_cor_', 't_comp_cor_', 'std_dvars'))
363-
]
364-
365353
workflow.connect([
366354
# connect inputnode to each non-anatomical confound node
367355
(inputnode, dvars, [('pet', 'in_file'),
@@ -392,10 +380,11 @@ def _select_cols(table):
392380
('petref2anat_xfm', 'transforms'),
393381
]),
394382
(acompcor_tfm, acompcor_bin, [('output_image', 'in_file')]),
383+
(union_mask, merge_rois, [('out', 'in1')]),
395384
(acompcor_bin, merge_rois, [
396-
(('out_mask', _last), 'in3'),
397-
(('out_mask', lambda masks: masks[0]), 'in1'),
398-
(('out_mask', lambda masks: masks[1]), 'in2'),
385+
(('out_mask', _first), 'in2'),
386+
(('out_mask', _second), 'in3'),
387+
(('out_mask', _last), 'in4'),
399388
]),
400389
(merge_rois, signals, [('out', 'label_files')]),
401390

@@ -590,6 +579,34 @@ def _binary_union(mask1, mask2):
590579
return str(out_name)
591580

592581

582+
def _smooth_binarize(in_file, fwhm=10.0, thresh=0.2):
583+
"""Smooth ``in_file`` with a Gaussian kernel, binarize and keep largest cluster."""
584+
from pathlib import Path
585+
586+
import nibabel as nb
587+
import numpy as np
588+
from scipy.ndimage import gaussian_filter, label
589+
590+
img = nb.load(in_file)
591+
data = img.get_fdata(dtype=np.float32)
592+
zooms = np.array(img.header.get_zooms()[:3], dtype=float)
593+
sigma = (fwhm / 2.3548) / zooms
594+
smoothed = gaussian_filter(data, sigma=sigma)
595+
mask = smoothed > (thresh * smoothed.max())
596+
597+
labeled, n_labels = label(mask)
598+
if n_labels > 1:
599+
sizes = np.bincount(labeled.ravel())
600+
sizes[0] = 0 # ignore background
601+
mask = labeled == sizes.argmax()
602+
603+
out_img = img.__class__(mask.astype('uint8'), img.affine, img.header)
604+
out_img.set_data_dtype('uint8')
605+
out_name = Path('smoothed_bin_mask.nii.gz').absolute()
606+
out_img.to_filename(out_name)
607+
return str(out_name)
608+
609+
593610
def _carpet_parcellation(segmentation, crown_mask, nifti=False):
594611
"""Generate a segmentation for carpet plot visualization."""
595612
from pathlib import Path
@@ -619,3 +636,31 @@ def _get_zooms(in_file):
619636
import nibabel as nb
620637

621638
return tuple(nb.load(in_file).header.get_zooms()[:3])
639+
640+
641+
def _last(inlist):
642+
"""Return the last element of a list."""
643+
644+
return inlist[-1]
645+
646+
647+
def _first(inlist):
648+
"""Return the first element of a list."""
649+
650+
return inlist[0]
651+
652+
653+
def _second(inlist):
654+
"""Return the second element of a list."""
655+
656+
return inlist[1]
657+
658+
def _select_cols(table):
659+
"""Return confound columns excluding a/tCompCor and std_dvars."""
660+
import pandas as pd
661+
662+
return [
663+
col
664+
for col in pd.read_table(table, nrows=2).columns
665+
if not col.startswith(('a_comp_cor_', 't_comp_cor_', 'std_dvars'))
666+
]

fmriprep/workflows/pet/fit.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ def init_pet_fit_wf(
228228
('subjects_dir', 'inputnode.subjects_dir'),
229229
('subject_id', 'inputnode.subject_id'),
230230
]),
231+
(petref_buffer, func_fit_reports_wf, [('petref', 'inputnode.petref')]),
231232
(outputnode, func_fit_reports_wf, [
232233
('pet_mask', 'inputnode.pet_mask'),
233234
('petref2anat_xfm', 'inputnode.petref2anat_xfm'),
@@ -356,15 +357,18 @@ def init_pet_fit_wf(
356357

357358
# Stage 4: Estimate PET brain mask
358359
from niworkflows.interfaces.fixes import FixHeaderApplyTransforms as ApplyTransforms
359-
from niworkflows.interfaces.nibabel import Binarize
360360

361-
from .confounds import _binary_union
361+
from .confounds import _binary_union, _smooth_binarize
362362

363363
t1w_mask_tfm = pe.Node(
364364
ApplyTransforms(interpolation='MultiLabel', invert_transform_flags=[True]),
365365
name='t1w_mask_tfm',
366366
)
367-
petref_mask = pe.Node(Binarize(thresh_low=0.2), name='petref_mask')
367+
petref_mask = pe.Node(
368+
niu.Function(function=_smooth_binarize), name='petref_mask'
369+
)
370+
petref_mask.inputs.fwhm = 10.0
371+
petref_mask.inputs.thresh = 0.2
368372
merge_mask = pe.Node(niu.Function(function=_binary_union), name='merge_mask')
369373

370374
if not petref2anat_xform:
@@ -379,7 +383,7 @@ def init_pet_fit_wf(
379383
(inputnode, t1w_mask_tfm, [('t1w_mask', 'input_image')]),
380384
(petref_buffer, t1w_mask_tfm, [('petref', 'reference_image')]),
381385
(petref_buffer, petref_mask, [('petref', 'in_file')]),
382-
(petref_mask, merge_mask, [('out_mask', 'mask1')]),
386+
(petref_mask, merge_mask, [('out', 'mask1')]),
383387
(t1w_mask_tfm, merge_mask, [('output_image', 'mask2')]),
384388
(merge_mask, outputnode, [('out', 'pet_mask')]),
385389
]

fmriprep/workflows/pet/outputs.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ def init_func_fit_reports_wf(
307307
pet_t1_report = pe.Node(
308308
SimpleBeforeAfter(
309309
before_label='T1w',
310-
after_label='EPI',
310+
after_label='PET',
311311
dismiss_affine=True,
312312
),
313313
name='pet_t1_report',
@@ -317,6 +317,7 @@ def init_func_fit_reports_wf(
317317
ds_pet_t1_report = pe.Node(
318318
DerivativesDataSink(
319319
base_directory=output_dir,
320+
desc='coreg',
320321
suffix='pet',
321322
datatype='figures',
322323
),

0 commit comments

Comments
 (0)