Skip to content

Commit 01e1921

Browse files
authored
Merge branch 'add_refmask_petprep' into main
2 parents 5d166f4 + da6b128 commit 01e1921

19 files changed

+945
-30
lines changed

docs/usage.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,13 @@ 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+
Reference region masks
207+
----------------------
208+
*PETPrep* can build masks for reference regions used in quantification.
209+
Use ``--ref-mask-name`` to select a predefined region and
210+
``--ref-mask-index`` to override the label indices.
211+
The presets are defined in ``petprep/data/reference_mask/config.json``.
212+
206213
Troubleshooting
207214
---------------
208215
Logs and crashfiles are output into the

petprep/cli/parser.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,18 @@ def _bids_filter(value, parser):
554554
help='Segmentation method to use.',
555555
)
556556

557+
g_refmask = parser.add_argument_group('Options for reference mask generation')
558+
g_refmask.add_argument(
559+
'--ref-mask-name',
560+
help='Predefined reference regions. Pair with --ref-mask-index to define custom labels.',
561+
)
562+
g_refmask.add_argument(
563+
'--ref-mask-index',
564+
nargs='+',
565+
type=int,
566+
help='List of segmentation indices to use for the reference mask.',
567+
)
568+
557569
g_pvc = parser.add_argument_group('Options for partial volume correction')
558570

559571
try:
@@ -721,6 +733,14 @@ def parse_args(args=None, namespace=None):
721733
'Options --pvc-tool, --pvc-method and --pvc-psf must be used together.'
722734
)
723735

736+
if opts.ref_mask_index is not None and opts.ref_mask_name is None:
737+
parser.error('Option --ref-mask-index requires --ref-mask-name.')
738+
739+
if opts.ref_mask_name is not None:
740+
config.workflow.ref_mask_name = opts.ref_mask_name
741+
if opts.ref_mask_index is not None:
742+
config.workflow.ref_mask_index = tuple(opts.ref_mask_index)
743+
724744
if opts.pvc_tool is not None:
725745
config.workflow.pvc_tool = opts.pvc_tool
726746
if opts.pvc_method is not None:

petprep/cli/tests/test_parser.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,3 +283,25 @@ def test_pvc_invalid_method(tmp_path, minimal_bids):
283283
with pytest.raises(SystemExit):
284284
parse_args(args=args)
285285
_reset_config()
286+
287+
288+
def test_reference_mask_options(tmp_path, minimal_bids, monkeypatch):
289+
work_dir = tmp_path / 'work'
290+
base_args = [
291+
str(minimal_bids),
292+
str(tmp_path / 'out'),
293+
'participant',
294+
'-w',
295+
str(work_dir),
296+
'--skip-bids-validation',
297+
]
298+
299+
# Missing --ref-mask-name should raise error when --ref-mask-index is used
300+
with pytest.raises(SystemExit):
301+
parse_args(args=base_args + ['--ref-mask-index', '1', '2'])
302+
_reset_config()
303+
304+
parse_args(args=base_args + ['--ref-mask-name', 'cerebellum', '--ref-mask-index', '3', '4'])
305+
assert config.workflow.ref_mask_name == 'cerebellum'
306+
assert config.workflow.ref_mask_index == (3, 4)
307+
_reset_config()

petprep/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,12 @@ class workflow(_Config):
607607
pvc_psf: tuple | None = None
608608
"""Point spread function (PSF) FWHM in mm for PVC."""
609609

610+
ref_mask_name: str | None = None
611+
"""Name of the reference region mask to generate."""
612+
613+
ref_mask_index: tuple | None = None
614+
"""Tuple of label indices used to build the reference mask."""
615+
610616
class loggers:
611617
"""Keep loggers easily accessible (see :py:func:`init`)."""
612618

petprep/data/io_spec.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@
4242
}
4343
},
4444
"patterns": [
45-
"sub-{subject}[/ses-{session}]/{datatype<func|pet>|pet}/sub-{subject}[_ses-{session}][_task-{task}][_acq-{acquisition}][_ce-{ceagent}][_rec-{reconstruction}][_res-{res}][_label-{label}][_echo-{echo}][_space-{space}][_desc-{desc}]_{suffix<bold|boldref|pet|petref|dseg|mask>}.{extension<nii|nii.gz|json>|nii.gz}",
45+
"sub-{subject}[/ses-{session}]/{datatype<func|pet>|pet}/sub-{subject}[_ses-{session}][_task-{task}][_acq-{acquisition}][_ce-{ceagent}][_rec-{reconstruction}][_ref-{ref}][_res-{res}][_label-{label}][_echo-{echo}][_space-{space}][_desc-{desc}]_{suffix<bold|boldref|pet|petref|dseg|mask>}.{extension<nii|nii.gz|json>|nii.gz}",
4646
"sub-{subject}[/ses-{session}]/{datatype<func|pet>|pet}/sub-{subject}[_ses-{session}][_task-{task}][_acq-{acquisition}][_ce-{ceagent}][_rec-{reconstruction}][_run-{run}]_from-{from}_to-{to}_mode-{mode<image|points>|image}_{suffix<xfm>|xfm}.{extension<txt|h5>}",
47-
"sub-{subject}[/ses-{session}]/{datatype<func|pet>|pet}/sub-{subject}[_ses-{session}][_task-{task}][_acq-{acquisition}][_ce-{ceagent}][_rec-{reconstruction}][_run-{run}][_part-{part}][_desc-{desc}]_{suffix<timeseries>}.{extension<tsv|json>}",
48-
"sub-{subject}[/ses-{session}]/{datatype<func|pet>|pet}/sub-{subject}[_ses-{session}][_task-{task}][_acq-{acquisition}][_ce-{ceagent}][_rec-{reconstruction}][_space-{space}][_res-{res}][_den-{den}][_hemi-{hemi}[_label-{label}][_desc-{desc}]_{suffix<|boldref|petref|dseg|mask>}.{extension<dtseries.nii|dtseries.json>}"
47+
"sub-{subject}[/ses-{session}]/{datatype<func|pet>|pet}/sub-{subject}[_ses-{session}][_task-{task}][_acq-{acquisition}][_ce-{ceagent}][_rec-{reconstruction}][_ref-{ref}][_run-{run}][_part-{part}][_desc-{desc}]_{suffix<timeseries>}.{extension<tsv|json>}",
48+
"sub-{subject}[/ses-{session}]/{datatype<func|pet>|pet}/sub-{subject}[_ses-{session}][_task-{task}][_acq-{acquisition}][_ce-{ceagent}][_rec-{reconstruction}][_ref-{ref}][_space-{space}][_res-{res}][_den-{den}][_hemi-{hemi}[_label-{label}][_desc-{desc}]_{suffix<|boldref|petref|dseg|mask>}.{extension<dtseries.nii|dtseries.json>}"
4949
]
5050
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"gtm": {
3+
"cerebellum":
4+
{
5+
"refmask_indices": [47, 8],
6+
"erode_by_voxels": 1,
7+
"exclude_indices": [7, 46, 172, 1007, 2007, 1009, 2009, 1011, 2011, 1013, 2013, 16],
8+
"dilate_by_voxels": 3
9+
}
10+
},
11+
"wm": {
12+
"semiovale":
13+
{
14+
"refmask_indices": [5001, 5002],
15+
"erode_by_voxels": 1,
16+
"exclude_indices": [10, 49, 12, 51, 13, 52, 11, 50, 4, 43, 31, 63],
17+
"dilate_by_voxels": 3,
18+
"smooth_fwhm_mm": 10,
19+
"target_volume_ml": 40
20+
}
21+
}
22+
}

petprep/data/reports-spec-pet.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ sections:
99
- bids: {datatype: figures, desc: carpetplot, suffix: pet}
1010
- bids: {datatype: figures, desc: confoundcorr, suffix: pet}
1111
- bids: {datatype: figures, desc: coreg, suffix: pet}
12+
- bids: {datatype: figures, desc: refmask, suffix: pet}
13+
caption: Reference region mask overlaid on the PET reference and anatomical data.
14+
static: false
15+
subtitle: Reference mask check
1216
- name: About
1317
reportlets:
1418
- bids: {datatype: figures, desc: about, suffix: T1w}

petprep/data/reports-spec.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,10 @@ sections:
138138
caption: PET to anatomical alignment check
139139
static: false
140140
subtitle: Additional PET Visualizations
141+
- bids: {datatype: figures, desc: refmask, suffix: pet}
142+
caption: Reference region mask overlaid on the PET reference and anatomical.
143+
static: false
144+
subtitle: Reference mask check
141145
- name: About
142146
nested: true
143147
reportlets:

petprep/interfaces/reference_mask.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
from nipype.interfaces.base import (
2+
SimpleInterface,
3+
BaseInterfaceInputSpec,
4+
TraitedSpec,
5+
File,
6+
traits,
7+
isdefined,
8+
)
9+
import os
10+
import nibabel as nib
11+
import json
12+
13+
14+
class ExtractRefRegionInputSpec(BaseInterfaceInputSpec):
15+
seg_file = File(exists=True, mandatory=True, desc="Segmentation NIfTI file")
16+
config_file = File(exists=True, mandatory=True, desc="Path to the config.json file")
17+
segmentation_type = traits.Str(mandatory=True, desc="Type of segmentation (e.g. 'gtm', 'wm')")
18+
region_name = traits.Str(
19+
mandatory=True, desc="Name of the reference region (e.g. 'cerebellum')"
20+
)
21+
override_indices = traits.List(traits.Int, desc='Use these indices instead of configuration')
22+
23+
24+
class ExtractRefRegionOutputSpec(TraitedSpec):
25+
refmask_file = File(exists=True, desc="Output reference mask NIfTI file")
26+
27+
28+
class ExtractRefRegion(SimpleInterface):
29+
input_spec = ExtractRefRegionInputSpec
30+
output_spec = ExtractRefRegionOutputSpec
31+
32+
def _run_interface(self, runtime):
33+
seg_img = nib.load(self.inputs.seg_file)
34+
35+
if isdefined(self.inputs.override_indices):
36+
cfg = {'refmask_indices': list(self.inputs.override_indices)}
37+
else:
38+
# Load the config
39+
with open(self.inputs.config_file, 'r') as f:
40+
config = json.load(f)
41+
42+
try:
43+
cfg = config[self.inputs.segmentation_type][self.inputs.region_name]
44+
except KeyError:
45+
raise ValueError(
46+
f"Configuration not found for segmentation='{self.inputs.segmentation_type}' "
47+
f"and region='{self.inputs.region_name}'"
48+
)
49+
50+
from petprep.utils.reference_mask import generate_reference_region
51+
52+
refmask_img = generate_reference_region(seg_img=seg_img, config=cfg)
53+
54+
out_file = os.path.abspath("refmask.nii.gz")
55+
nib.save(refmask_img, out_file)
56+
self._results["refmask_file"] = out_file
57+
return runtime
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import json
2+
from pathlib import Path
3+
4+
import nibabel as nb
5+
import numpy as np
6+
from nipype.pipeline import engine as pe
7+
8+
from ..reference_mask import ExtractRefRegion
9+
10+
11+
def _create_seg(tmp_path: Path) -> Path:
12+
data = np.zeros((5, 5, 5), dtype=np.uint8)
13+
data[1, 1, 1] = 1
14+
data[2, 2, 2] = 2
15+
img = nb.Nifti1Image(data, np.eye(4))
16+
seg_file = tmp_path / "seg.nii.gz"
17+
img.to_filename(seg_file)
18+
return seg_file
19+
20+
21+
def _create_config(tmp_path: Path, indices, extra=None):
22+
cfg = {"testseg": {"region": {"refmask_indices": indices}}}
23+
if extra:
24+
cfg["testseg"]["region"].update(extra)
25+
cfg_file = tmp_path / "config.json"
26+
cfg_file.write_text(json.dumps(cfg))
27+
return cfg_file
28+
29+
30+
def test_extract_refregion(tmp_path):
31+
seg = _create_seg(tmp_path)
32+
cfg = _create_config(tmp_path, [1])
33+
34+
node = pe.Node(
35+
ExtractRefRegion(
36+
seg_file=str(seg),
37+
config_file=str(cfg),
38+
segmentation_type="testseg",
39+
region_name="region",
40+
),
41+
name="er", base_dir=str(tmp_path)
42+
)
43+
res = node.run()
44+
out = nb.load(res.outputs.refmask_file).get_fdata()
45+
assert out[1, 1, 1] == 1
46+
assert out.sum() == 1
47+
48+
49+
def test_extract_refregion_override(tmp_path):
50+
seg = _create_seg(tmp_path)
51+
cfg = _create_config(tmp_path, [1])
52+
53+
node = pe.Node(
54+
ExtractRefRegion(
55+
seg_file=str(seg),
56+
config_file=str(cfg),
57+
segmentation_type="testseg",
58+
region_name="region",
59+
override_indices=[2],
60+
),
61+
name="er2", base_dir=str(tmp_path)
62+
)
63+
res = node.run()
64+
out = nb.load(res.outputs.refmask_file).get_fdata()
65+
assert out[2, 2, 2] == 1
66+
assert out.sum() == 1
67+
68+
69+
def test_extract_refregion_override_ignores_config(tmp_path):
70+
seg = _create_seg(tmp_path)
71+
cfg = _create_config(
72+
tmp_path,
73+
[1],
74+
{
75+
"exclude_indices": [2],
76+
"erode_by_voxels": 1,
77+
},
78+
)
79+
80+
node = pe.Node(
81+
ExtractRefRegion(
82+
seg_file=str(seg),
83+
config_file=str(cfg),
84+
segmentation_type="testseg",
85+
region_name="region",
86+
override_indices=[2],
87+
),
88+
name='er3',
89+
base_dir=str(tmp_path),
90+
)
91+
res = node.run()
92+
out = nb.load(res.outputs.refmask_file).get_fdata()
93+
assert out[2, 2, 2] == 1
94+
assert out.sum() == 1
95+
96+
97+
def test_extract_refregion_override_missing_config(tmp_path):
98+
seg = _create_seg(tmp_path)
99+
cfg_file = tmp_path / 'config.json'
100+
cfg_file.write_text(json.dumps({'testseg': {}}))
101+
102+
node = pe.Node(
103+
ExtractRefRegion(
104+
seg_file=str(seg),
105+
config_file=str(cfg_file),
106+
segmentation_type='testseg',
107+
region_name='missing',
108+
override_indices=[2],
109+
),
110+
name='er4',
111+
base_dir=str(tmp_path),
112+
)
113+
res = node.run()
114+
out = nb.load(res.outputs.refmask_file).get_fdata()
115+
assert out[2, 2, 2] == 1
116+
assert out.sum() == 1

0 commit comments

Comments
 (0)