Skip to content

Commit 7a5e24e

Browse files
authored
Merge pull request #39 from nipreps/add_extract_tacs_petprep
This PR implements support for extracting time activity curves from the PET data, given a specific segmentation. If a --ref-mask is specified, it will also extract a time activity curve for this region. If PVC correction is chosen, it will extract the time activity curve from the PVC corrected data. Other relevant changes: Expanded the outputs documentation with explanations of the TACs workflows. It now shows how both segmentation-based TACs and reference-region TACs are generated and named Implemented a new ExtractRefTAC interface to compute TACs from reference masks and included it in a dedicated init_pet_ref_tacs_wf workflow
2 parents 8e2a669 + 7f9e2f5 commit 7a5e24e

File tree

14 files changed

+640
-115
lines changed

14 files changed

+640
-115
lines changed

docs/outputs.rst

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -330,14 +330,30 @@ corresponding PET time series::
330330
662.82715 0.0 487.37302 1.01591 15.281561 0.0333937 0.3328022893 -0.2220965269 -0.0912891436 0.2326688125 0.279138129 -0.111878887 0.16901660629999998 0.0550480212 0.1798747037 -0.25383302620000003 0.1646403629 0.3953613889 0.0 0.010164 -0.0103568 0.0424513 0.0 -0.0 0.00019174
331331

332332
**Time activity curves**.
333-
For each PET file, mean uptake values are extracted from the anatomical segmentation.
334-
The resulting table has ``FrameTimesStart`` and ``FrameTimesEnd`` columns followed
335-
by one column per region::
333+
The workflow :func:`petprep.workflows.pet.tacs.init_pet_tacs_wf` extracts mean uptake
334+
from an anatomical segmentation. The resulting table has ``FrameTimesStart`` and
335+
``FrameTimesEnd`` columns followed by one column per region::
336336

337337
sub-<subject_label>/
338338
pet/
339339
sub-<subject_label>_[specifiers]_desc-preproc_timeseries.tsv
340340

341+
If partial volume correction is applied, the filenames also include the
342+
``_pvc-<method>`` entity, indicating the algorithm used.
343+
344+
If a reference mask is specified, the workflow
345+
:func:`petprep.workflows.pet.ref_tacs.init_pet_ref_tacs_wf` extracts a separate
346+
table containing the mean uptake within that region::
347+
348+
sub-<subject_label>/
349+
pet/
350+
sub-<subject_label>_[specifiers]_desc-<seg>_ref-<ref>_timeseries.tsv
351+
352+
The ``ref`` entity captures the reference region identifier provided via the
353+
:ref:`CLI options <cli_refmask>` ``--ref-mask-name`` and ``--ref-mask-index``.
354+
When partial volume correction is performed, the ``_pvc-<method>`` entity is
355+
also included.
356+
341357
Confounds
342358
---------
343359
The :abbr:`BOLD (blood-oxygen level dependent)` signal measured with fMRI is a mixture of fluctuations

docs/usage.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,8 @@ method with a 5 mm PSF::
203203
$ petprep /data/bids_root /out participant \
204204
--pvc-tool petpvc --pvc-method GTM --pvc-psf 5
205205

206+
.. _cli_refmask:
207+
206208
Reference region masks
207209
----------------------
208210
*PETPrep* can build masks for reference regions used in quantification.

petprep/cli/workflow.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -151,15 +151,15 @@ def build_workflow(config_file, retval):
151151
missing = check_deps(retval['workflow'])
152152
if missing:
153153
build_log.critical(
154-
'Cannot run fMRIPrep. Missing dependencies:%s',
154+
'Cannot run PETPrep. Missing dependencies:%s',
155155
'\n\t* '.join([''] + [f'{cmd} (Interface: {iface})' for iface, cmd in missing]),
156156
)
157157
retval['return_code'] = 127 # 127 == command not found.
158158
return retval
159159

160160
config.to_filename(config_file)
161161
build_log.info(
162-
'fMRIPrep workflow graph with %d nodes built successfully.',
162+
'PETPrep workflow graph with %d nodes built successfully.',
163163
len(retval['workflow']._get_all_nodes()),
164164
)
165165
retval['return_code'] = 0
@@ -194,7 +194,7 @@ def build_boilerplate(config_file, workflow):
194194

195195
bib_text = data.load.readable('boilerplate.bib').read_text()
196196
citation_files['bib'].write_text(
197-
bib_text.replace('fMRIPrep <version>', f'fMRIPrep {config.environment.version}')
197+
bib_text.replace('PETPrep <version>', f'PETPrep {config.environment.version}')
198198
)
199199

200200
# Generate HTML file resolving citations
@@ -205,7 +205,7 @@ def build_boilerplate(config_file, workflow):
205205
str(citation_files['bib']),
206206
'--citeproc',
207207
'--metadata',
208-
'pagetitle="fMRIPrep citation boilerplate"',
208+
'pagetitle="PETPrep citation boilerplate"',
209209
str(citation_files['md']),
210210
'-o',
211211
str(citation_files['html']),

petprep/interfaces/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from niworkflows.interfaces.bids import DerivativesDataSink as _DDSink
44

55
from .cifti import GeneratePetCifti
6-
from .tacs import ExtractTACs
6+
from .tacs import ExtractTACs, ExtractRefTAC
77

88

99
class DerivativesDataSink(_DDSink):
@@ -14,4 +14,5 @@ class DerivativesDataSink(_DDSink):
1414
'DerivativesDataSink',
1515
'GeneratePetCifti',
1616
'ExtractTACs',
17+
'ExtractRefTAC',
1718
)

petprep/interfaces/tacs.py

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@
33
import nibabel as nb
44
import numpy as np
55
import pandas as pd
6-
from nipype.interfaces.base import BaseInterfaceInputSpec, File, SimpleInterface, TraitedSpec
6+
from nipype.interfaces.base import (
7+
BaseInterfaceInputSpec,
8+
File,
9+
SimpleInterface,
10+
TraitedSpec,
11+
)
712
from nipype.utils.filemanip import fname_presuffix
8-
from niworkflows.utils.timeseries import _nifti_timeseries
13+
from nipype.interfaces.base import traits
914

1015

1116
class _ExtractTACsInputSpec(BaseInterfaceInputSpec):
@@ -81,4 +86,58 @@ def _run_interface(self, runtime):
8186
return runtime
8287

8388

84-
__all__ = ('ExtractTACs',)
89+
class _ExtractRefTACInputSpec(BaseInterfaceInputSpec):
90+
in_file = File(exists=True, mandatory=True, desc="PET file in anatomical space")
91+
mask_file = File(
92+
exists=True, mandatory=True, desc="Reference mask in anatomical space"
93+
)
94+
ref_mask_name = traits.Str(mandatory=True, desc="Name of reference region")
95+
metadata = File(exists=True, mandatory=True, desc="PET JSON metadata file")
96+
97+
98+
class _ExtractRefTACOutputSpec(TraitedSpec):
99+
out_file = File(exists=True, desc="Reference region time activity curve")
100+
101+
102+
class ExtractRefTAC(SimpleInterface):
103+
"""Extract a time activity curve from a reference mask."""
104+
105+
input_spec = _ExtractRefTACInputSpec
106+
output_spec = _ExtractRefTACOutputSpec
107+
108+
def _run_interface(self, runtime):
109+
pet_img = nb.load(self.inputs.in_file)
110+
pet_data = pet_img.get_fdata()
111+
if pet_img.ndim == 3:
112+
pet_data = pet_data[..., np.newaxis]
113+
114+
mask = nb.load(self.inputs.mask_file).get_fdata() > 0
115+
116+
with open(self.inputs.metadata) as f:
117+
metadata = json.load(f)
118+
119+
frame_times = metadata.get("FrameTimesStart", [])
120+
frame_durations = metadata.get("FrameDuration", [])
121+
122+
if len(frame_times) != len(frame_durations):
123+
raise ValueError("FrameTimesStart and FrameDuration must have equal length")
124+
125+
timeseries = pet_data[mask, :].mean(axis=0)
126+
frame_times_end = np.add(frame_times, frame_durations).tolist()
127+
df = pd.DataFrame({self.inputs.ref_mask_name: timeseries})
128+
df.insert(0, "FrameTimesEnd", frame_times_end)
129+
df.insert(0, "FrameTimesStart", list(frame_times))
130+
131+
out_file = fname_presuffix(
132+
self.inputs.in_file,
133+
suffix="_timeseries.tsv",
134+
newpath=runtime.cwd,
135+
use_ext=False,
136+
)
137+
df.to_csv(out_file, sep="\t", index=False, na_rep="n/a")
138+
139+
self._results["out_file"] = out_file
140+
return runtime
141+
142+
143+
__all__ = ("ExtractTACs", "ExtractRefTAC")

petprep/interfaces/tests/test_tacs.py

Lines changed: 166 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import gzip
12
import json
3+
import pickle
4+
from pathlib import Path
25

36
import nibabel as nb
47
import numpy as np
@@ -7,14 +10,19 @@
710
from nipype.pipeline import engine as pe
811
from nipype.pipeline.engine.nodes import NodeExecutionError
912

10-
from petprep.interfaces.tacs import ExtractTACs
13+
from petprep.interfaces.tacs import ExtractRefTAC, ExtractTACs
14+
from petprep.workflows.pet.ref_tacs import init_pet_ref_tacs_wf
15+
from petprep.workflows.pet.tacs import init_pet_tacs_wf
1116

1217

1318
def test_ExtractTACs(tmp_path):
14-
pet_data = np.stack([
15-
np.ones((2, 2, 2)),
16-
np.ones((2, 2, 2)) * 2,
17-
], axis=-1)
19+
pet_data = np.stack(
20+
[
21+
np.ones((2, 2, 2)),
22+
np.ones((2, 2, 2)) * 2,
23+
],
24+
axis=-1,
25+
)
1826
pet_file = tmp_path / 'pet.nii.gz'
1927
nb.Nifti1Image(pet_data, np.eye(4)).to_filename(pet_file)
2028

@@ -47,10 +55,13 @@ def test_ExtractTACs(tmp_path):
4755

4856

4957
def test_ExtractTACs_mismatched_meta(tmp_path):
50-
pet_data = np.stack([
51-
np.ones((2, 2, 2)),
52-
np.ones((2, 2, 2)) * 2,
53-
], axis=-1)
58+
pet_data = np.stack(
59+
[
60+
np.ones((2, 2, 2)),
61+
np.ones((2, 2, 2)) * 2,
62+
],
63+
axis=-1,
64+
)
5465
pet_file = tmp_path / 'pet.nii.gz'
5566
nb.Nifti1Image(pet_data, np.eye(4)).to_filename(pet_file)
5667

@@ -76,4 +87,149 @@ def test_ExtractTACs_mismatched_meta(tmp_path):
7687
)
7788

7889
with pytest.raises(NodeExecutionError):
79-
node.run()
90+
node.run()
91+
92+
93+
def test_tacs_workflow(tmp_path):
94+
"""Workflow passes resampled PET to ExtractTACs."""
95+
pet_data = np.stack(
96+
[
97+
np.ones((2, 2, 2)),
98+
np.ones((2, 2, 2)) * 2,
99+
],
100+
axis=-1,
101+
)
102+
pet_file = tmp_path / 'pet.nii.gz'
103+
nb.Nifti1Image(pet_data, np.eye(4)).to_filename(pet_file)
104+
105+
seg_data = np.tile([[1, 2], [1, 2]], (2, 1, 1)).astype('int16')
106+
seg_file = tmp_path / 'seg.nii.gz'
107+
nb.Nifti1Image(seg_data, np.eye(4)).to_filename(seg_file)
108+
109+
dseg_tsv = tmp_path / 'seg.tsv'
110+
pd.DataFrame({'index': [1, 2], 'name': ['A', 'B']}).to_csv(dseg_tsv, sep='\t', index=False)
111+
112+
meta_json = tmp_path / 'pet.json'
113+
meta_json.write_text(json.dumps({'FrameTimesStart': [0, 1], 'FrameDuration': [1, 1]}))
114+
115+
wf = init_pet_tacs_wf()
116+
wf.base_dir = str(tmp_path)
117+
wf.config['execution']['remove_unnecessary_outputs'] = False
118+
wf.inputs.inputnode.pet_anat = str(pet_file)
119+
wf.inputs.inputnode.segmentation = str(seg_file)
120+
wf.inputs.inputnode.dseg_tsv = str(dseg_tsv)
121+
wf.inputs.inputnode.metadata = str(meta_json)
122+
123+
wf.run()
124+
125+
resampled_pet = tmp_path / 'pet_tacs_wf' / 'resample_pet' / 'pet_resampled.nii.gz'
126+
tac_inputs_file = tmp_path / 'pet_tacs_wf' / 'tac' / '_inputs.pklz'
127+
with gzip.open(tac_inputs_file, 'rb') as f:
128+
inputs = pickle.load(f)
129+
130+
assert inputs['in_file'] == str(resampled_pet)
131+
assert Path(tmp_path / 'pet_tacs_wf' / 'tac' / 'pet_resampled_timeseries.tsv').exists()
132+
133+
134+
def test_ExtractRefTAC(tmp_path):
135+
pet_data = np.stack(
136+
[
137+
np.ones((2, 2, 2)),
138+
np.ones((2, 2, 2)) * 2,
139+
],
140+
axis=-1,
141+
)
142+
pet_file = tmp_path / "pet.nii.gz"
143+
nb.Nifti1Image(pet_data, np.eye(4)).to_filename(pet_file)
144+
145+
mask_data = np.zeros((2, 2, 2), dtype="int16")
146+
mask_data[0] = 1
147+
mask_file = tmp_path / "mask.nii.gz"
148+
nb.Nifti1Image(mask_data, np.eye(4)).to_filename(mask_file)
149+
150+
meta_json = tmp_path / "pet.json"
151+
meta_json.write_text(
152+
json.dumps({"FrameTimesStart": [0, 1], "FrameDuration": [1, 1]})
153+
)
154+
155+
node = pe.Node(
156+
ExtractRefTAC(
157+
in_file=str(pet_file),
158+
mask_file=str(mask_file),
159+
ref_mask_name="ref",
160+
metadata=str(meta_json),
161+
),
162+
name="tac",
163+
base_dir=tmp_path,
164+
)
165+
res = node.run()
166+
167+
out = pd.read_csv(res.outputs.out_file, sep="\t")
168+
assert list(out.columns) == ["FrameTimesStart", "FrameTimesEnd", "ref"]
169+
assert np.allclose(out["ref"], [1, 2])
170+
171+
172+
def test_ExtractRefTAC_mismatched_meta(tmp_path):
173+
pet_data = np.stack(
174+
[
175+
np.ones((2, 2, 2)),
176+
np.ones((2, 2, 2)) * 2,
177+
],
178+
axis=-1,
179+
)
180+
pet_file = tmp_path / "pet.nii.gz"
181+
nb.Nifti1Image(pet_data, np.eye(4)).to_filename(pet_file)
182+
183+
mask_data = np.zeros((2, 2, 2), dtype="int16")
184+
mask_data[0] = 1
185+
mask_file = tmp_path / "mask.nii.gz"
186+
nb.Nifti1Image(mask_data, np.eye(4)).to_filename(mask_file)
187+
188+
meta_json = tmp_path / "pet.json"
189+
meta_json.write_text(json.dumps({"FrameTimesStart": [0], "FrameDuration": [1, 1]}))
190+
191+
node = pe.Node(
192+
ExtractRefTAC(
193+
in_file=str(pet_file),
194+
mask_file=str(mask_file),
195+
ref_mask_name="ref",
196+
metadata=str(meta_json),
197+
),
198+
name="tac2",
199+
base_dir=tmp_path,
200+
)
201+
202+
with pytest.raises(NodeExecutionError):
203+
node.run()
204+
205+
206+
def test_ref_tacs_workflow_mismatched_meta(tmp_path):
207+
"""Workflow should fail with inconsistent metadata."""
208+
pet_data = np.stack(
209+
[
210+
np.ones((2, 2, 2)),
211+
np.ones((2, 2, 2)) * 2,
212+
],
213+
axis=-1,
214+
)
215+
pet_file = tmp_path / 'pet.nii.gz'
216+
nb.Nifti1Image(pet_data, np.eye(4)).to_filename(pet_file)
217+
218+
mask_data = np.zeros((2, 2, 2), dtype='int16')
219+
mask_data[0] = 1
220+
mask_file = tmp_path / 'mask.nii.gz'
221+
nb.Nifti1Image(mask_data, np.eye(4)).to_filename(mask_file)
222+
223+
meta_json = tmp_path / 'pet.json'
224+
meta_json.write_text(json.dumps({'FrameTimesStart': [0], 'FrameDuration': [1, 1]}))
225+
226+
wf = init_pet_ref_tacs_wf()
227+
wf.base_dir = str(tmp_path)
228+
wf.config['execution']['remove_unnecessary_outputs'] = False
229+
wf.inputs.inputnode.pet_anat = str(pet_file)
230+
wf.inputs.inputnode.mask_file = str(mask_file)
231+
wf.inputs.inputnode.metadata = str(meta_json)
232+
wf.inputs.inputnode.ref_mask_name = 'ref'
233+
234+
with pytest.raises(NodeExecutionError):
235+
wf.run()

petprep/workflows/pet/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@
2020
from .registration import init_pet_reg_wf
2121
from .resampling import init_pet_surf_wf
2222
from .tacs import init_pet_tacs_wf
23+
from .ref_tacs import init_pet_ref_tacs_wf
2324

2425
__all__ = [
2526
'init_pet_confs_wf',
2627
'init_pet_hmc_wf',
2728
'init_pet_reg_wf',
2829
'init_pet_surf_wf',
2930
'init_pet_tacs_wf',
31+
'init_pet_ref_tacs_wf',
3032
]

0 commit comments

Comments
 (0)