Skip to content

Commit 7c9c0ab

Browse files
authored
Merge pull request #133 from bilgelm/pet-bids-derivatives
Align outputs with PET-BIDS derivatives
2 parents f1cfd04 + 2199135 commit 7c9c0ab

File tree

16 files changed

+287
-143
lines changed

16 files changed

+287
-143
lines changed

README.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Dynamic PET <img src="docs/logo.png" align="right" height="150" />
1+
# Dynamic PET <img src="docs/logo.png" align="right" height="150" alt="Dynamic PET logo"/>
22

33
<!-- [![PyPI](https://img.shields.io/pypi/v/dynamicpet.svg)][pypi_]
44
[![Status](https://img.shields.io/pypi/status/dynamicpet.svg)][status]
@@ -34,6 +34,7 @@ Methods implemented in the CLI include:
3434

3535
- Denoising
3636
- HighlY constrained backPRojection method constraining the backprojections to Local Regions of interest ([HYPR-LR])
37+
- Nonlocal EStimation of multispectral MAgnitudes ([NESMA])
3738
- Reference tissue-based modeling
3839
- Standardized Uptake Value Ratio (SUVR)
3940
- Logan Reference Tissue Model ([LRTM])
@@ -47,6 +48,7 @@ Several implementations of estimating SRTM parameters are available:
4748

4849
[lrtm]: https://doi.org/10.1097/00004647-199609000-00008
4950
[hypr-lr]: https://doi.org/10.2967/jnumed.109.073999
51+
[nesma]: https://doi.org/10.1111/jon.12537
5052

5153
## Requirements
5254

@@ -60,8 +62,8 @@ _Dynamic PET_ requires Python 3.11+ and the following modules:
6062
You can install _Dynamic PET_ via [pip] after cloning the repository:
6163

6264
```console
63-
$ git clone https://github.com/bilgelm/dynamicpet.git
64-
$ pip install -e dynamicpet
65+
git clone https://github.com/bilgelm/dynamicpet.git
66+
pip install -e dynamicpet
6567
```
6668

6769
## Usage
@@ -78,13 +80,13 @@ You will then need to create a binary mask that is in the same space as the PET
7880
After installing _Dynamic PET_ as described above, execute:
7981

8082
```console
81-
$ kineticmodel PET --model SRTMZhou2003 --refmask <REFMASK> --outputdir <OUTPUTDIR> --fwhm 5
83+
kineticmodel PET --model SRTMZhou2003 --refmask <REFMASK> --outputdir <OUTPUTDIR> --fwhm 5
8284
```
8385

8486
where
8587

8688
```console
87-
$ PET=<OPENNEURODATA>/ds001705-download/sub-000101/ses-baseline/pet/sub-000101_ses-baseline_pet.nii
89+
PET=<OPENNEURODATA>/ds001705-download/sub-000101/ses-baseline/pet/sub-000101_ses-baseline_pet.nii
8890
```
8991

9092
Before running these commands, replace

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "dynamicpet"
3-
version = "0.1.3"
3+
version = "0.1.4"
44
description = "Dynamic PET"
55
authors = ["Murat Bilgel <[email protected]>"]
66
license = "MIT"

src/dynamicpet/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,6 @@
11
"""Dynamic PET."""
2+
3+
import importlib.metadata
4+
5+
6+
__version__ = importlib.metadata.version("dynamicpet")

src/dynamicpet/__main__.py

Lines changed: 130 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@
22

33
import csv
44
import os
5+
import re
56
import warnings
7+
from json import dump as json_dump
68

79
import click
810
import numpy as np
911
from nibabel.filename_parser import splitext_addext
1012
from nibabel.loadsave import load as nib_load
1113
from nibabel.spatialimages import SpatialImage
1214

15+
from dynamicpet import __version__
1316
from dynamicpet.denoise import hypr
1417
from dynamicpet.denoise import nesma
1518
from dynamicpet.kineticmodel.kineticmodel import KineticModel
@@ -100,19 +103,31 @@ def denoise(
100103
) -> None:
101104
"""Perform dynamic PET denoising.
102105
103-
Outputs will have a '_<method>' suffix.
106+
Outputs will have a '_desc-<method>_pet' suffix.
104107
105108
PET: 4-D PET image
106109
"""
107110
# load PET
108111
pet_img = petbidsimage_load(pet, json)
109112

110113
if method == "HYPRLR":
114+
doc = hypr.hypr_lr.__doc__
115+
116+
# parameters not relevant to HYPR-LR
117+
mask = None
118+
window_half_size = None
119+
thresh = None
120+
111121
if fwhm:
112122
res = hypr.hypr_lr(pet_img, fwhm)
113123
else:
114124
raise ValueError("fwhm must be specified for HYPR-LR")
115125
elif method == "NESMA":
126+
doc = nesma.nesma_semiadaptive.__doc__
127+
128+
# parameter not relevant to NESMA
129+
fwhm = None
130+
116131
if mask:
117132
mask_img: SpatialImage = nib_load(mask) # type: ignore
118133
# check that mask is in the same space as pet
@@ -138,10 +153,37 @@ def denoise(
138153
if outputdir:
139154
os.makedirs(outputdir, exist_ok=True)
140155
bname = os.path.basename(froot)
141-
froot = os.path.join(outputdir, bname)
142-
output = froot + "_" + method.lower() + ext + addext
156+
# if the input file name follows the PET-BIDS convention, it should end
157+
# with "_pet". Need to move this to the end of the new file name to
158+
# maintain compatibility with the PET-BIDS Derivatives convention.
159+
froot = re.sub("_pet$", "", os.path.join(outputdir, bname))
160+
output = froot + "_desc-" + method.lower() + "_pet" + ext + addext
161+
output_json = froot + "_desc-" + method.lower() + "_pet.json"
143162
res.to_filename(output)
144163

164+
cmd = (
165+
f"denoise --method {method} "
166+
+ (f"--fwhm {fwhm} " if fwhm else "")
167+
+ (f"--mask {mask} " if mask else "")
168+
+ (f"--window_half_size {window_half_size} " if window_half_size else "")
169+
+ (f"--thresh {thresh} " if thresh else "")
170+
+ (f"--outputdir {outputdir} " if outputdir else "")
171+
+ (f"--json {json} " if json else "")
172+
+ pet
173+
)
174+
derivative_json_dict = {
175+
"Description": (
176+
re.sub(r"\s+", " ", doc.split("Args:")[0]).strip() if doc else ""
177+
),
178+
# "Sources": [pet],
179+
"SoftwareName": "dynamicpet",
180+
"SoftwareVersion": __version__,
181+
"CommandLine": cmd,
182+
}
183+
184+
with open(output_json, "w") as f:
185+
json_dump(derivative_json_dict, f, indent=4)
186+
145187

146188
@click.command()
147189
@click.argument("pet", type=str)
@@ -200,9 +242,11 @@ def denoise(
200242
),
201243
)
202244
@click.option(
203-
"--start", default=None, type=float, help="Start of time window for model"
245+
"--start", default=None, type=float, help="Start of time window for model in min"
246+
)
247+
@click.option(
248+
"--end", default=None, type=float, help="End of time window for model in min"
204249
)
205-
@click.option("--end", default=None, type=float, help="End of time window for model")
206250
@click.option(
207251
"--fwhm",
208252
default=None,
@@ -226,7 +270,7 @@ def denoise(
226270
"'rect' is rectangular integration."
227271
),
228272
)
229-
def kineticmodel(
273+
def kineticmodel( # noqa: C901
230274
pet: str,
231275
model: str,
232276
refroi: str | None,
@@ -242,7 +286,7 @@ def kineticmodel(
242286
) -> None:
243287
"""Fit a reference tissue model to a dynamic PET image or TACs.
244288
245-
Outputs will have a '_km-<model>_kp-<parameter>' suffix.
289+
Outputs will have a '_model-<model>_meas-<parameter>' suffix.
246290
247291
PET: 4-D PET image (can be 3-D if model is SUVR) or a 2-D tabular TACs tsv file
248292
"""
@@ -262,16 +306,27 @@ def kineticmodel(
262306
pet_img = pet_img.extract(start, end)
263307
reftac = reftac.extract(start, end)
264308

309+
if fwhm and model not in ["srtmzhou2003"]:
310+
fwhm = None
311+
warnings.warn(
312+
"--fwhm argument is not relevant for this model, will be ignored",
313+
RuntimeWarning,
314+
stacklevel=2,
315+
)
316+
265317
# fit kinetic model
266318
km: KineticModel
267319
match model:
268320
case "suvr":
321+
model_abbr = "SUVR"
269322
km = SUVR(reftac, pet_img)
270323
km.fit(mask=petmask_img_mat)
271324
case "srtmlammertsma1996":
325+
model_abbr = "SRTM"
272326
km = SRTMLammertsma1996(reftac, pet_img)
273327
km.fit(mask=petmask_img_mat, weight_by=weight_by)
274328
case "srtmzhou2003":
329+
model_abbr = "SRTM"
275330
km = SRTMZhou2003(reftac, pet_img)
276331
km.fit(
277332
mask=petmask_img_mat,
@@ -288,7 +343,13 @@ def kineticmodel(
288343
froot = os.path.join(outputdir, bname)
289344

290345
if isinstance(pet_img, PETBIDSMatrix):
291-
output = froot + "_km-" + model.replace(".", "") + ext
346+
# if the input file name follows the PET-BIDS Derivatives convention,
347+
# it should end with "_tacs". Need to remove this to maintain
348+
# compatibility with the PET-BIDS Derivatives convention.
349+
froot = re.sub("_tacs$", "", froot)
350+
351+
output = froot + "_model-" + model_abbr + "_kinpar" + ext
352+
output_json = froot + "_model-" + model_abbr + "_kinpar.json"
292353
data = np.empty((len(km.parameters), pet_img.num_elements))
293354
for i, param in enumerate(km.parameters.keys()):
294355
data[i] = km.get_parameter(param)
@@ -299,16 +360,73 @@ def kineticmodel(
299360
for i, elem in enumerate(pet_img.elem_names):
300361
tsvwriter.writerow([elem] + datat[i].tolist())
301362
else:
363+
# if the input file name follows the PET-BIDS convention, it should end
364+
# with "_pet". Need to remove this to maintain compatibility with the
365+
# PET-BIDS Derivatives convention.
366+
froot = re.sub("_pet$", "", froot)
367+
302368
# save estimated parameters as image
303369
for param in km.parameters.keys():
304370
res_img: SpatialImage = km.get_parameter(param) # type: ignore
305371
output = (
306-
froot + "_km-" + model.replace(".", "") + "_kp-" + param + ext + addext
372+
froot
373+
+ "_model-"
374+
+ model_abbr
375+
+ "_meas-"
376+
+ param
377+
+ "_mimap"
378+
+ ext
379+
+ addext
307380
)
308381
res_img.to_filename(output)
309-
310-
# also need to save a json PET BIDS derivative file
311-
# TODO
382+
output_json = froot + "_model-" + model_abbr + "_mimap.json"
383+
384+
# save json PET BIDS derivative file
385+
inputvalues = [start, end]
386+
inputvalueslabels = [
387+
"Start of time window for model",
388+
"End of time window for model",
389+
]
390+
inputvaluesunits = ["min", "min"]
391+
392+
if fwhm:
393+
inputvalues += [fwhm]
394+
inputvalueslabels += ["Full width at half max"]
395+
inputvaluesunits += ["mm"]
396+
397+
cmd = (
398+
f"kineticmodel --model {model} "
399+
+ (f"--refroi {refroi} " if refroi else f"--refmask {refmask} ")
400+
+ (f"--outputdir {outputdir} " if outputdir else "")
401+
+ (f"--json {json} " if json else "")
402+
+ (f"--petmask {petmask} " if petmask else "")
403+
+ f"--start {start} "
404+
+ f"--end {end} "
405+
+ (f"--fwhm {fwhm} " if fwhm else "")
406+
+ f"--weight_by {weight_by} "
407+
+ f"--integration_type {integration_type} "
408+
+ pet
409+
)
410+
doc = km.__class__.__doc__
411+
derivative_json_dict = {
412+
"Description": re.sub(r"\s+", " ", doc) if doc else "",
413+
# "Sources": [pet],
414+
"ModelName": model_abbr,
415+
"ReferenceRegion": refroi if refroi else refmask,
416+
"AdditionalModelDetails": (
417+
f"Frame weighting by: {weight_by}. "
418+
+ f"Integration type: {integration_type}.",
419+
),
420+
"InputValues": inputvalues,
421+
"InputValuesLabels": inputvalueslabels,
422+
"InputValuesUnits": inputvaluesunits,
423+
"SoftwareName": "dynamicpet",
424+
"SoftwareVersion": __version__,
425+
"CommandLine": cmd,
426+
}
427+
428+
with open(output_json, "w") as f:
429+
json_dump(derivative_json_dict, f, indent=4)
312430

313431

314432
def parse_kineticmodel_inputs(

src/dynamicpet/kineticmodel/kineticmodel.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ class KineticModel(ABC):
3535
@abstractmethod
3636
def get_param_names(cls) -> list[str]:
3737
"""Get names of kinetic model parameters."""
38+
# parameter names should contain alphanumeric characters only
3839
raise NotImplementedError
3940

4041
def __init__(
@@ -110,11 +111,11 @@ def get_parameter(self, param_name: str) -> SpatialImage | NumpyNumberArray:
110111
else:
111112
param_vector: NumpyNumberArray = self.parameters[param_name]
112113
return param_vector
113-
elif param_name == "bp" and "dvr" in self.parameters:
114-
self.parameters[param_name] = self.parameters["dvr"] - 1
114+
elif param_name == "BPND" and "DVR" in self.parameters:
115+
self.parameters[param_name] = self.parameters["DVR"] - 1
115116
return self.get_parameter(param_name)
116-
elif param_name == "dvr" and "bp" in self.parameters:
117-
self.parameters[param_name] = self.parameters["bp"] + 1
117+
elif param_name == "DVR" and "BPND" in self.parameters:
118+
self.parameters[param_name] = self.parameters["BPND"] + 1
118119
return self.get_parameter(param_name)
119120
else:
120121
raise AttributeError(

src/dynamicpet/kineticmodel/logan.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,15 @@ class LRTM(KineticModel):
3030
@classmethod
3131
def get_param_names(cls) -> list[str]:
3232
"""Get names of kinetic model parameters."""
33-
return ["dvr"]
33+
return ["DVR"]
3434

3535
def fit( # noqa: max-complexity: 12
3636
self,
3737
mask: NumpyNumberArray | None = None,
3838
integration_type: INTEGRATION_TYPE_OPTS = "trapz",
3939
weight_by: WEIGHT_OPTS | NumpyNumberArray | None = "frame_duration",
4040
tstar: float = 0,
41-
k2: float | None = None,
41+
k2prime: float | None = None,
4242
) -> None:
4343
"""Estimate model parameters.
4444
@@ -56,7 +56,8 @@ def fit( # noqa: max-complexity: 12
5656
to fit the kinetic model. Elements outside the mask will
5757
be set to to 0 in parametric estimate outputs.
5858
tstar: time beyond which to assume linearity
59-
k2: (avg.) effective tissue-to-plasma efflux constant, in unit of 1/min
59+
k2prime: (avg.) effective tissue-to-plasma efflux constant in the
60+
reference region, in unit of 1/min
6061
"""
6162
# get reference TAC as a 1-D vector
6263
reftac: NumpyNumberArray = self.reftac.dataobj.flatten()[:, np.newaxis]
@@ -82,7 +83,7 @@ def fit( # noqa: max-complexity: 12
8283

8384
dvr = np.zeros((num_elements, 1))
8485

85-
if not k2:
86+
if not k2prime:
8687
# TODO
8788
# Check Eq. 7 assumption (i.e., that tac / reftac is reasonably
8889
# constant) by calculating R2 etc. for each tac.
@@ -103,13 +104,14 @@ def fit( # noqa: max-complexity: 12
103104

104105
# ----- Get DVR -----
105106
# Set up the weighted linear regression model based on Logan et al.:
106-
# - use Eq. 6 if k2 is provided
107-
# - use Eq. 7 if k2 is not provided
107+
# - use Eq. 6 if k2prime is provided
108+
# - use Eq. 7 if k2prime is not provided
108109

109110
x = np.column_stack(
110111
(
111112
np.ones_like(tac_tstar),
112-
(int_reftac_tstar + (reftac_tstar / k2 if k2 else 0)) / tac_tstar,
113+
(int_reftac_tstar + (reftac_tstar / k2prime if k2prime else 0))
114+
/ tac_tstar,
113115
)
114116
)
115117
y = int_tac_tstar / tac_tstar
@@ -123,8 +125,8 @@ def fit( # noqa: max-complexity: 12
123125
# distribution volume ratio
124126
dvr[k] = b[1]
125127

126-
self.set_parameter("dvr", dvr, mask)
127-
# should tstar (and k2?) also be stored?
128+
self.set_parameter("DVR", dvr, mask)
129+
# should tstar (and k2prime?) also be stored?
128130

129131
def fitted_tacs(self) -> TemporalMatrix | TemporalImage:
130132
"""Get fitted TACs based on estimated model parameters."""

0 commit comments

Comments
 (0)