id-uptake-values 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- id_uptake_values-0.1.0/PKG-INFO +53 -0
- id_uptake_values-0.1.0/README.md +40 -0
- id_uptake_values-0.1.0/pyproject.toml +25 -0
- id_uptake_values-0.1.0/src/id_uptake_values/__init__.py +0 -0
- id_uptake_values-0.1.0/src/id_uptake_values/adf.py +69 -0
- id_uptake_values-0.1.0/src/id_uptake_values/debug_plots.py +121 -0
- id_uptake_values-0.1.0/src/id_uptake_values/estimation.py +28 -0
- id_uptake_values-0.1.0/src/id_uptake_values/models.py +72 -0
- id_uptake_values-0.1.0/src/id_uptake_values/py.typed +0 -0
- id_uptake_values-0.1.0/src/id_uptake_values/resampling.py +50 -0
- id_uptake_values-0.1.0/src/id_uptake_values/sul.py +64 -0
- id_uptake_values-0.1.0/src/id_uptake_values/suv.py +54 -0
- id_uptake_values-0.1.0/src/id_uptake_values/weights/knn-melanoma-fdg/activity/X.npy +0 -0
- id_uptake_values-0.1.0/src/id_uptake_values/weights/knn-melanoma-fdg/lbm/X.npy +0 -0
- id_uptake_values-0.1.0/src/id_uptake_values/weights/knn-melanoma-fdg/weight/X.npy +0 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: id-uptake-values
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author: Christian Hinge
|
|
6
|
+
Author-email: Christian Hinge <christian.hinge@regionh.dk>
|
|
7
|
+
Requires-Dist: argparse>=1.4.0
|
|
8
|
+
Requires-Dist: matplotlib>=3.11.2
|
|
9
|
+
Requires-Dist: nibabel>=5.4.2
|
|
10
|
+
Requires-Dist: scipy>=1.18.1
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# ID-uptake-values
|
|
15
|
+
|
|
16
|
+
Estimate SUV (body-mass normalised) and SUL (lean-body-mass normalised) PET images **without** DICOM metadata such as injected dose or patient weight. Total activity and body mass / lean body mass are inferred from the images themselves, using TotalSegmentator masks and a k-nearest-neighbour model over axial distribution functions.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
pip install . # or: uv sync
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The model weights are bundled with the package.
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
Inputs are NIfTI files: PET, CT, and TotalSegmentator segmentations (`total`, `body`, and for SUL also `tissue`).
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
# SUV
|
|
32
|
+
python -m id_uptake_values.suv --pet pet.nii.gz --ct ct.nii.gz \
|
|
33
|
+
--totalseg total.nii.gz --bodyseg body.nii.gz \
|
|
34
|
+
--output-image suv.nii.gz --output-json suv.json
|
|
35
|
+
|
|
36
|
+
# SUL (optionally with a debug plot of the alignment and nearest-neighbour fit)
|
|
37
|
+
python -m id_uptake_values.sul --pet pet.nii.gz --ct ct.nii.gz \
|
|
38
|
+
--totalseg total.nii.gz --tissueseg tissue.nii.gz --bodyseg body.nii.gz \
|
|
39
|
+
--output-image sul.nii.gz --output-json sul.json --debug-image debug.png
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The JSON contains the estimated activity (MBq), the estimated body mass or lean body mass (kg), and the denominator the PET image was divided by.
|
|
43
|
+
|
|
44
|
+
The scan must cover the region from C1 to the sacrum; otherwise a `ValueError` is raised.
|
|
45
|
+
|
|
46
|
+
## Development
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
uv sync
|
|
50
|
+
uv run pytest
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The tests download subject `sub-000` from the [DEPICT-RH/Multimodal-HC](https://huggingface.co/datasets/DEPICT-RH/Multimodal-HC) dataset into the Hugging Face cache.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# ID-uptake-values
|
|
2
|
+
|
|
3
|
+
Estimate SUV (body-mass normalised) and SUL (lean-body-mass normalised) PET images **without** DICOM metadata such as injected dose or patient weight. Total activity and body mass / lean body mass are inferred from the images themselves, using TotalSegmentator masks and a k-nearest-neighbour model over axial distribution functions.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pip install . # or: uv sync
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The model weights are bundled with the package.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
Inputs are NIfTI files: PET, CT, and TotalSegmentator segmentations (`total`, `body`, and for SUL also `tissue`).
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
# SUV
|
|
19
|
+
python -m id_uptake_values.suv --pet pet.nii.gz --ct ct.nii.gz \
|
|
20
|
+
--totalseg total.nii.gz --bodyseg body.nii.gz \
|
|
21
|
+
--output-image suv.nii.gz --output-json suv.json
|
|
22
|
+
|
|
23
|
+
# SUL (optionally with a debug plot of the alignment and nearest-neighbour fit)
|
|
24
|
+
python -m id_uptake_values.sul --pet pet.nii.gz --ct ct.nii.gz \
|
|
25
|
+
--totalseg total.nii.gz --tissueseg tissue.nii.gz --bodyseg body.nii.gz \
|
|
26
|
+
--output-image sul.nii.gz --output-json sul.json --debug-image debug.png
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The JSON contains the estimated activity (MBq), the estimated body mass or lean body mass (kg), and the denominator the PET image was divided by.
|
|
30
|
+
|
|
31
|
+
The scan must cover the region from C1 to the sacrum; otherwise a `ValueError` is raised.
|
|
32
|
+
|
|
33
|
+
## Development
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
uv sync
|
|
37
|
+
uv run pytest
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The tests download subject `sub-000` from the [DEPICT-RH/Multimodal-HC](https://huggingface.co/datasets/DEPICT-RH/Multimodal-HC) dataset into the Hugging Face cache.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ID-uptake-values"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Christian Hinge", email = "christian.hinge@regionh.dk" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"argparse>=1.4.0",
|
|
12
|
+
"matplotlib>=3.11.2",
|
|
13
|
+
"nibabel>=5.4.2",
|
|
14
|
+
"scipy>=1.18.1",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["uv_build>=0.8.16,<0.9.0"]
|
|
19
|
+
build-backend = "uv_build"
|
|
20
|
+
|
|
21
|
+
[dependency-groups]
|
|
22
|
+
dev = ["pytest>=8", "huggingface-hub>=0.25"]
|
|
23
|
+
|
|
24
|
+
[tool.pytest.ini_options]
|
|
25
|
+
testpaths = ["tests"]
|
|
File without changes
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#%%
|
|
2
|
+
import numpy as np
|
|
3
|
+
import nibabel as nib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import os
|
|
6
|
+
from scipy.ndimage import binary_fill_holes
|
|
7
|
+
from nibabel.processing import resample_from_to
|
|
8
|
+
|
|
9
|
+
def axial_distribution(img):
|
|
10
|
+
img = nib.funcs.as_closest_canonical(img)
|
|
11
|
+
img_y = img.get_fdata().sum(axis=(0,1)) * np.prod(img.header.get_zooms()[:2]) / 1000
|
|
12
|
+
nz = img.shape[-1]
|
|
13
|
+
img_x = img.affine @ np.array([np.zeros(nz),np.zeros(nz),np.arange(nz),np.ones(nz)])
|
|
14
|
+
img_x = img_x[-2,:]
|
|
15
|
+
return img_x, img_y
|
|
16
|
+
|
|
17
|
+
def axial_distribution_mask(img, num_classes=13):
|
|
18
|
+
dataobj = np.asanyarray(img.dataobj)
|
|
19
|
+
nz = img.shape[-1]
|
|
20
|
+
img_y = np.zeros((nz,num_classes))
|
|
21
|
+
for ix in range(1,num_classes+1):
|
|
22
|
+
img_y[:,ix-1] = (dataobj==ix).sum(axis=(0,1))
|
|
23
|
+
img_x = img.affine @ np.array([np.zeros(nz),np.zeros(nz),np.arange(nz),np.ones(nz)])
|
|
24
|
+
img_x = img_x[-2,:]
|
|
25
|
+
img_y*= np.prod(img.header.get_zooms()[:2]) / 1000
|
|
26
|
+
return img_x, img_y
|
|
27
|
+
|
|
28
|
+
def get_ct_cylindrical_mask(ct_image):
|
|
29
|
+
mask = ct_image.get_fdata() > -1023.5
|
|
30
|
+
mask = mask.any(axis=2,keepdims=True)
|
|
31
|
+
mask = binary_fill_holes(mask)
|
|
32
|
+
mask = np.repeat(mask,ct_image.shape[2],axis=2)
|
|
33
|
+
return nib.Nifti1Image(mask.astype("uint8"),affine=ct_image.affine)
|
|
34
|
+
|
|
35
|
+
def crop_pet_activity_to_ct_axial_fov(pet_img,ct_image):
|
|
36
|
+
mask = get_ct_cylindrical_mask(ct_image)
|
|
37
|
+
mask = resample_from_to(mask,pet_img,order=0)
|
|
38
|
+
pet_arr = pet_img.get_fdata().copy()
|
|
39
|
+
pet_arr[mask.get_fdata()==0] = 0
|
|
40
|
+
return nib.Nifti1Image(pet_arr,pet_img.affine)
|
|
41
|
+
|
|
42
|
+
def make_distribution_functions(pet_img, ts_total_img, ts_body_img, ts_tissue_img,ct_img, out_directory=None):
|
|
43
|
+
if out_directory is not None:
|
|
44
|
+
os.makedirs(out_directory,exist_ok=True)
|
|
45
|
+
if not isinstance(out_directory,Path) and out_directory is not None:
|
|
46
|
+
out_directory = Path(out_directory)
|
|
47
|
+
data = {}
|
|
48
|
+
|
|
49
|
+
x,y = axial_distribution_mask(ts_total_img,num_classes=117)
|
|
50
|
+
data["ts_total_x"] = x
|
|
51
|
+
data["ts_total_y"] = y
|
|
52
|
+
|
|
53
|
+
pet_img = crop_pet_activity_to_ct_axial_fov(pet_img,ct_img)
|
|
54
|
+
x,y = axial_distribution(pet_img)
|
|
55
|
+
data["pet_x"] = x
|
|
56
|
+
data["pet_y"] = y
|
|
57
|
+
|
|
58
|
+
x,y = axial_distribution_mask(ts_body_img,num_classes=2)
|
|
59
|
+
data["ts_body_x"] = x
|
|
60
|
+
data["ts_body_y"] = y
|
|
61
|
+
|
|
62
|
+
if ts_tissue_img is not None:
|
|
63
|
+
x,y = axial_distribution_mask(ts_tissue_img,num_classes=3)
|
|
64
|
+
data["ts_tissues_x"] = x
|
|
65
|
+
data["ts_tissues_y"] = y
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
return data
|
|
69
|
+
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import matplotlib.pyplot as plt
|
|
4
|
+
import nibabel as nib
|
|
5
|
+
import numpy as np
|
|
6
|
+
from nibabel.processing import resample_from_to
|
|
7
|
+
|
|
8
|
+
from .adf import make_distribution_functions, get_ct_cylindrical_mask
|
|
9
|
+
from .resampling import C1toSacrumResampler
|
|
10
|
+
from .models import KNNLeanBodyMass, KNNActivity
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _z_normalized(img, head, hip):
|
|
14
|
+
k = np.arange(img.shape[2])
|
|
15
|
+
z_mm = (img.affine @ np.stack([np.zeros_like(k), np.zeros_like(k), k, np.ones_like(k)]))[2]
|
|
16
|
+
return (z_mm - head) / (head - hip)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _overlay(mask, color=(1, 0, 0, 0.45)):
|
|
20
|
+
out = np.zeros(mask.shape + (4,))
|
|
21
|
+
out[mask] = color
|
|
22
|
+
return out
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _band(img_canon, mip, head, hip, cmap, vmin, vmax, label):
|
|
26
|
+
zoom = img_canon.header.get_zooms()[:3]
|
|
27
|
+
return {
|
|
28
|
+
"mip": mip,
|
|
29
|
+
"zn": _z_normalized(img_canon, head, hip),
|
|
30
|
+
"span": abs(_z_normalized(img_canon, head, hip)[-1] - _z_normalized(img_canon, head, hip)[0]),
|
|
31
|
+
"t_over_z": (img_canon.shape[0] * zoom[0]) / (img_canon.shape[2] * zoom[2]),
|
|
32
|
+
"cmap": cmap,
|
|
33
|
+
"vmin": vmin,
|
|
34
|
+
"vmax": vmax,
|
|
35
|
+
"label": label,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _draw_adf(ax, model, adfs, band, name, prefix, greek, scale, unit):
|
|
40
|
+
x, yr, resmask = model._resample(adfs)
|
|
41
|
+
distance = model._distance(yr, resmask)
|
|
42
|
+
ixs = np.argsort(distance)[:model.n_neighbors]
|
|
43
|
+
nn_mean = model._nn_estimate(distance)
|
|
44
|
+
fov_fraction = nn_mean[resmask].sum()
|
|
45
|
+
fov_sum = model.fov_sum(adfs)
|
|
46
|
+
total = fov_sum / fov_fraction
|
|
47
|
+
scaled_patient = yr / yr.sum() * fov_fraction
|
|
48
|
+
neighbors = model.X[ixs]
|
|
49
|
+
ymax = max(neighbors.max(), scaled_patient.max())
|
|
50
|
+
|
|
51
|
+
for neighbor in neighbors:
|
|
52
|
+
ax.plot(x, neighbor, color="0.5", linewidth=0.6, alpha=0.5)
|
|
53
|
+
ax.plot([], [], color="0.5", linewidth=0.6, label=f"K={model.n_neighbors} nearest neighbors")
|
|
54
|
+
ax.plot(x, nn_mean, color="k", linewidth=2, label="Nearest neighbors mean")
|
|
55
|
+
ax.plot(x, scaled_patient, color="r", linewidth=2, label="Patient scaled to nn-mean")
|
|
56
|
+
ax.fill_between(x, 0, scaled_patient, where=resmask, color="r", alpha=0.2)
|
|
57
|
+
for pos, color, label in ((0, "tab:blue", "C1 (z = 0)"), (-1, "tab:green", "Sacrum (z = -1)")):
|
|
58
|
+
ix = np.argmin(np.abs(x - pos))
|
|
59
|
+
ax.axvline(pos, color=color, linewidth=0.8, zorder=4)
|
|
60
|
+
ax.scatter([x[ix]], [scaled_patient[ix]], color=color, edgecolor="k", s=70, zorder=5, label=label)
|
|
61
|
+
ax.axvline(x[resmask].min(), color="k", linestyle="--", alpha=0.5)
|
|
62
|
+
ax.axvline(x[resmask].max(), color="k", linestyle="--", alpha=0.5)
|
|
63
|
+
|
|
64
|
+
window = ax.get_window_extent()
|
|
65
|
+
xrange_ = 0.75 - (-3)
|
|
66
|
+
frac = band["span"] / xrange_ * (window.width / window.height) * band["t_over_z"]
|
|
67
|
+
ytop = ymax / (1 - frac - 0.02)
|
|
68
|
+
height = frac * ytop
|
|
69
|
+
bottom = ytop - height
|
|
70
|
+
ax.imshow(band["mip"], origin="lower", cmap=band["cmap"], vmin=band["vmin"], vmax=band["vmax"],
|
|
71
|
+
aspect="auto", extent=[band["zn"][0], band["zn"][-1], bottom, bottom + height], zorder=3)
|
|
72
|
+
if band["label"]:
|
|
73
|
+
ax.text(max(band["zn"][0], -3), bottom + height + 0.01 * ytop, band["label"], fontsize=9, va="bottom")
|
|
74
|
+
|
|
75
|
+
ax.set_xlim(-3, 0.75)
|
|
76
|
+
ax.set_ylim(0, ytop)
|
|
77
|
+
ax.set_title(f"{name}\n{prefix}_total = {total / scale:.1f} {unit}, FOV fraction {greek} = {fov_fraction * 100:.1f}%")
|
|
78
|
+
ax.set_xlabel("Normalized axial position (z)")
|
|
79
|
+
ax.set_ylabel("Volume distribution density")
|
|
80
|
+
ax.legend(loc="lower left", bbox_to_anchor=(0.01, 0.3), fontsize=8)
|
|
81
|
+
return total, fov_sum, fov_fraction
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def create_debug_image(pet_img, ct_img, ts_total_img, ts_tissue_img, ts_body_img, out_path, model_dir):
|
|
85
|
+
adfs = make_distribution_functions(pet_img, ts_total_img, ts_body_img, ts_tissue_img, ct_img)
|
|
86
|
+
model_dose = KNNActivity(n_neighbors=40)
|
|
87
|
+
model_dose.load_weights(Path(model_dir) / "activity")
|
|
88
|
+
model_volume = KNNLeanBodyMass(n_neighbors=40)
|
|
89
|
+
model_volume.load_weights(Path(model_dir) / "lbm")
|
|
90
|
+
sul_denominator = model_dose.predict(adfs) / model_volume.predict(adfs)
|
|
91
|
+
|
|
92
|
+
resampler = C1toSacrumResampler(adfs["ts_total_x"], adfs["ts_total_y"])
|
|
93
|
+
head, hip = resampler.head_offset, resampler.hip_offset
|
|
94
|
+
|
|
95
|
+
sul_canon = nib.funcs.as_closest_canonical(nib.Nifti1Image(pet_img.get_fdata() / sul_denominator, affine=pet_img.affine))
|
|
96
|
+
ct_canon = nib.funcs.as_closest_canonical(ct_img)
|
|
97
|
+
crop_mask = resample_from_to(get_ct_cylindrical_mask(ct_img), pet_img, order=0)
|
|
98
|
+
crop_arr = nib.funcs.as_closest_canonical(crop_mask).get_fdata()
|
|
99
|
+
sul_arr = sul_canon.get_fdata()
|
|
100
|
+
|
|
101
|
+
pet_band = _band(sul_canon, sul_arr.max(axis=1), head, hip, "gray_r", 0, 5, "")
|
|
102
|
+
ct_mip = np.clip(ct_canon.get_fdata(), -200, 250).mean(axis=1)
|
|
103
|
+
ct_band = _band(ct_canon, ct_mip, head, hip, "gray_r", ct_mip.min(), np.percentile(ct_mip, 99.9), "")
|
|
104
|
+
|
|
105
|
+
fig, (ax_td, ax_dose, ax_lbm) = plt.subplots(1, 3, figsize=(21, 7.5))
|
|
106
|
+
|
|
107
|
+
ax_td.imshow(sul_arr.max(axis=2).T, origin="lower", cmap="gray_r", vmin=0, vmax=5, aspect="equal")
|
|
108
|
+
ax_td.imshow(_overlay((crop_arr.max(axis=2) == 0).T, color=(1, 0, 0, 0.2)), origin="lower", aspect="equal")
|
|
109
|
+
ax_td.set_title("Top-down projection (SUL MIP) with cropping mask\nred = cropped by CT cylindrical mask")
|
|
110
|
+
ax_td.set_xlabel("Transaxial voxel (x)")
|
|
111
|
+
ax_td.set_ylabel("Transaxial voxel (y, posterior to anterior)")
|
|
112
|
+
|
|
113
|
+
_draw_adf(ax_dose, model_dose, adfs, pet_band, "Dose ADF (PET)",
|
|
114
|
+
prefix="A", greek="\u03b1", scale=1e6, unit="MBq")
|
|
115
|
+
_draw_adf(ax_lbm, model_volume, adfs, ct_band, "Lean body mass ADF (CT)",
|
|
116
|
+
prefix="LBM", greek="\u03b2", scale=1e3, unit="kg")
|
|
117
|
+
|
|
118
|
+
out_path = Path(out_path)
|
|
119
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
fig.savefig(out_path, dpi=150, bbox_inches="tight")
|
|
121
|
+
plt.close(fig)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import nibabel as nib
|
|
4
|
+
|
|
5
|
+
from .adf import make_distribution_functions
|
|
6
|
+
from .models import KNNActivity
|
|
7
|
+
|
|
8
|
+
knn_model = Path(__file__).parent / "weights/knn-melanoma-fdg"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def estimate_dose_and_volume(pet_img, ct_img, ts_total_img, ts_tissue_img, ts_body_img, volume_model, volume_dir):
|
|
12
|
+
adfs = make_distribution_functions(pet_img, ts_total_img, ts_body_img, ts_tissue_img, ct_img)
|
|
13
|
+
model_dose = KNNActivity(n_neighbors=40)
|
|
14
|
+
model_dose.load_weights(knn_model / "activity")
|
|
15
|
+
model_volume = volume_model(n_neighbors=40)
|
|
16
|
+
model_volume.load_weights(volume_dir)
|
|
17
|
+
return model_dose.predict(adfs), model_volume.predict(adfs)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_outputs(pet_img, total_dose, total_volume, quantity, volume_key):
|
|
21
|
+
denominator = total_dose / total_volume
|
|
22
|
+
constants = {
|
|
23
|
+
f"estimated_{quantity}_denominator": float(denominator),
|
|
24
|
+
"estimated_activity_MBq": float(total_dose) / 1e6,
|
|
25
|
+
volume_key: float(total_volume) / 1e3,
|
|
26
|
+
}
|
|
27
|
+
img = nib.Nifti1Image(pet_img.get_fdata() / denominator, affine=pet_img.affine)
|
|
28
|
+
return img, constants
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from .resampling import C1toSacrumResampler
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class KNNModel:
|
|
9
|
+
def __init__(self, n_neighbors, distance_function="MAE"):
|
|
10
|
+
self.n_neighbors = n_neighbors
|
|
11
|
+
self.distance_function = distance_function
|
|
12
|
+
|
|
13
|
+
def load_batch(self, data):
|
|
14
|
+
raise NotImplementedError("load_batch not implemented")
|
|
15
|
+
|
|
16
|
+
def fov_sum(self, data):
|
|
17
|
+
x, y = self.load_batch(data)
|
|
18
|
+
return abs(x[0] - x[1]) * (y.sum())
|
|
19
|
+
|
|
20
|
+
def load_weights(self, directory):
|
|
21
|
+
self.X = np.load(Path(directory) / "X.npy")
|
|
22
|
+
|
|
23
|
+
def _distance(self, yr, resmask):
|
|
24
|
+
X = self.X[:, resmask]
|
|
25
|
+
X /= X.sum(axis=-1, keepdims=True)
|
|
26
|
+
yr = yr[resmask].reshape(1, -1)
|
|
27
|
+
yr = yr / yr.sum()
|
|
28
|
+
if self.distance_function == "MAE":
|
|
29
|
+
distance = np.abs(yr - X).mean(axis=-1)
|
|
30
|
+
elif self.distance_function == "MSE":
|
|
31
|
+
distance = np.square(yr - X).mean(axis=-1)
|
|
32
|
+
else:
|
|
33
|
+
raise Exception("Unknown distance function")
|
|
34
|
+
return distance
|
|
35
|
+
|
|
36
|
+
def _resample(self, data):
|
|
37
|
+
x, y = data["ts_total_x"], data["ts_total_y"]
|
|
38
|
+
xmetric, ymetric = self.load_batch(data)
|
|
39
|
+
resampler = C1toSacrumResampler(x, y)
|
|
40
|
+
_, yr, resmask = resampler(xmetric, ymetric)
|
|
41
|
+
return _, yr, resmask
|
|
42
|
+
|
|
43
|
+
def _nn_estimate(self, distance):
|
|
44
|
+
ixs = np.argsort(distance)[:self.n_neighbors]
|
|
45
|
+
Xmu = self.X[ixs, :].mean(axis=0)
|
|
46
|
+
Xmu = Xmu / Xmu.sum()
|
|
47
|
+
return Xmu
|
|
48
|
+
|
|
49
|
+
def predict(self, data):
|
|
50
|
+
fov_sum = self.fov_sum(data)
|
|
51
|
+
_, yr, resmask = self._resample(data)
|
|
52
|
+
distance = self._distance(yr, resmask)
|
|
53
|
+
nn_mean = self._nn_estimate(distance)
|
|
54
|
+
fov_fraction = nn_mean[resmask].sum()
|
|
55
|
+
return fov_sum * 1 / fov_fraction
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class KNNActivity(KNNModel):
|
|
59
|
+
def load_batch(self, data):
|
|
60
|
+
return data["pet_x"], data["pet_y"]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class KNNLeanBodyMass(KNNModel):
|
|
64
|
+
def load_batch(self, data):
|
|
65
|
+
lbm = data["ts_body_y"].sum(axis=1) - 0.76 * (data["ts_tissues_y"][:, 0] + data["ts_tissues_y"][:, 1])
|
|
66
|
+
lbm[lbm < 0] = 0
|
|
67
|
+
return data["ts_body_x"], lbm
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class KNNBodyVolume(KNNModel):
|
|
71
|
+
def load_batch(self, data):
|
|
72
|
+
return data["ts_body_x"], data["ts_body_y"].sum(axis=1)
|
|
File without changes
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def in_fov_volume_ml(x, y):
|
|
5
|
+
dz = np.abs(np.diff(x[:2]))
|
|
6
|
+
return (y.sum() * dz).item()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class C1toSacrumResampler:
|
|
10
|
+
def __init__(self, xts, yts, range=[0.75, -3], sample_points=1024):
|
|
11
|
+
sacrum_adf = yts[:, 25 - 1]
|
|
12
|
+
c1_adf = yts[:, 50 - 1]
|
|
13
|
+
|
|
14
|
+
if (c1_vol := in_fov_volume_ml(xts, c1_adf)) < 0.5:
|
|
15
|
+
raise ValueError(f"C1 volume too small {c1_vol:.2f}ml")
|
|
16
|
+
|
|
17
|
+
if (c1_vol := in_fov_volume_ml(xts, sacrum_adf)) < 2:
|
|
18
|
+
raise ValueError(f"Sacrum volume too small {c1_vol:.2f}ml")
|
|
19
|
+
|
|
20
|
+
self.head_offset = xts[np.argmax(c1_adf)]
|
|
21
|
+
self.hip_offset = xts[np.argmax(sacrum_adf)]
|
|
22
|
+
|
|
23
|
+
self.range = range
|
|
24
|
+
self.x_interp = np.linspace(self.range[0], self.range[1], sample_points)
|
|
25
|
+
|
|
26
|
+
def _interp(self, x, y):
|
|
27
|
+
return np.interp(self.x_interp, (x - self.head_offset) / (self.head_offset - self.hip_offset), y, right=0, left=0)
|
|
28
|
+
|
|
29
|
+
def _check_fix_data_mask(self, data_mask):
|
|
30
|
+
if not data_mask.any():
|
|
31
|
+
raise ValueError("data_mask is empty")
|
|
32
|
+
|
|
33
|
+
indices = np.nonzero(data_mask)
|
|
34
|
+
first = indices[0][0]
|
|
35
|
+
last = indices[0][-1]
|
|
36
|
+
x_probe = (self.x_interp >= -1) & (self.x_interp <= 0)
|
|
37
|
+
data_mask[first:last + 1] = True
|
|
38
|
+
|
|
39
|
+
if not data_mask[x_probe].all():
|
|
40
|
+
raise ValueError("data fov does not cover C1 to sacrum")
|
|
41
|
+
|
|
42
|
+
return data_mask
|
|
43
|
+
|
|
44
|
+
def __call__(self, x, y):
|
|
45
|
+
data_mask = y > 0
|
|
46
|
+
y_interp = self._interp(x, y)
|
|
47
|
+
data_mask = self._interp(x, data_mask)
|
|
48
|
+
data_mask = data_mask == 1
|
|
49
|
+
self._check_fix_data_mask(data_mask)
|
|
50
|
+
return self.x_interp, y_interp, data_mask
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import nibabel as nib
|
|
6
|
+
|
|
7
|
+
from .debug_plots import create_debug_image
|
|
8
|
+
from .estimation import build_outputs, estimate_dose_and_volume, knn_model
|
|
9
|
+
from .models import KNNLeanBodyMass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def sul_id(pet_img, ct_img, ts_total_img, ts_tissue_img, ts_body_img):
|
|
13
|
+
total_dose, total_volume = estimate_dose_and_volume(pet_img, ct_img, ts_total_img, ts_tissue_img, ts_body_img,
|
|
14
|
+
KNNLeanBodyMass, knn_model / "lbm")
|
|
15
|
+
return build_outputs(pet_img, total_dose, total_volume, "sul", "estimated_lbm_kg")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_args():
|
|
19
|
+
parser = argparse.ArgumentParser(description="Estimate SUL (lean-body-mass-corrected SUV) from PET/CT and TotalSegmentator masks.")
|
|
20
|
+
parser.add_argument("--pet", required=True, type=Path, help="Path to PET image (NIfTI).")
|
|
21
|
+
parser.add_argument("--ct", required=True, type=Path, help="Path to CT image (NIfTI).")
|
|
22
|
+
parser.add_argument("--totalseg", required=True, type=Path, help="Path to TotalSegmentator 'total' segmentation (NIfTI).")
|
|
23
|
+
parser.add_argument("--tissueseg", required=True, type=Path, help="Path to TotalSegmentator tissue segmentation (NIfTI).")
|
|
24
|
+
parser.add_argument("--bodyseg", required=True, type=Path, help="Path to TotalSegmentator body segmentation (NIfTI).")
|
|
25
|
+
parser.add_argument("--output-image", type=Path, default=None, help="Path to save the SUL image (NIfTI).")
|
|
26
|
+
parser.add_argument("--output-json", type=Path, default=None, help="Path to save the SUL constants (JSON).")
|
|
27
|
+
parser.add_argument("--debug-image", type=Path, default=None, help="Path to save a debug PNG of the axial cropping and ADF.")
|
|
28
|
+
args = parser.parse_args()
|
|
29
|
+
|
|
30
|
+
if args.output_image is None and args.output_json is None and args.debug_image is None:
|
|
31
|
+
parser.error("at least one of --output-image, --output-json or --debug-image must be given")
|
|
32
|
+
|
|
33
|
+
return args
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main():
|
|
37
|
+
args = parse_args()
|
|
38
|
+
|
|
39
|
+
pet_img = nib.load(args.pet)
|
|
40
|
+
ct_img = nib.load(args.ct)
|
|
41
|
+
ts_total_img = nib.load(args.totalseg)
|
|
42
|
+
ts_tissue_img = nib.load(args.tissueseg)
|
|
43
|
+
ts_body_img = nib.load(args.bodyseg)
|
|
44
|
+
|
|
45
|
+
if args.debug_image is not None:
|
|
46
|
+
create_debug_image(pet_img, ct_img, ts_total_img, ts_tissue_img, ts_body_img, args.debug_image, knn_model)
|
|
47
|
+
|
|
48
|
+
if args.output_image is None and args.output_json is None:
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
sul_img, constants = sul_id(pet_img, ct_img, ts_total_img, ts_tissue_img, ts_body_img)
|
|
52
|
+
|
|
53
|
+
if args.output_image is not None:
|
|
54
|
+
args.output_image.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
nib.save(sul_img, args.output_image)
|
|
56
|
+
|
|
57
|
+
if args.output_json is not None:
|
|
58
|
+
args.output_json.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
with open(args.output_json, "w") as handle:
|
|
60
|
+
json.dump(constants, handle, sort_keys=True, indent=4)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
main()
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import nibabel as nib
|
|
6
|
+
|
|
7
|
+
from .estimation import build_outputs, estimate_dose_and_volume, knn_model
|
|
8
|
+
from .models import KNNBodyVolume
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def suv_id(pet_img, ct_img, ts_total_img, ts_body_img):
|
|
12
|
+
total_dose, total_body_mass = estimate_dose_and_volume(pet_img, ct_img, ts_total_img, None, ts_body_img,
|
|
13
|
+
KNNBodyVolume, knn_model / "weight")
|
|
14
|
+
return build_outputs(pet_img, total_dose, total_body_mass, "suv", "estimated_body_mass_kg")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_args():
|
|
18
|
+
parser = argparse.ArgumentParser(description="Estimate SUV (body-mass-corrected) from PET/CT and TotalSegmentator masks.")
|
|
19
|
+
parser.add_argument("--pet", required=True, type=Path, help="Path to PET image (NIfTI).")
|
|
20
|
+
parser.add_argument("--ct", required=True, type=Path, help="Path to CT image (NIfTI).")
|
|
21
|
+
parser.add_argument("--totalseg", required=True, type=Path, help="Path to TotalSegmentator 'total' segmentation (NIfTI).")
|
|
22
|
+
parser.add_argument("--bodyseg", required=True, type=Path, help="Path to TotalSegmentator body segmentation (NIfTI).")
|
|
23
|
+
parser.add_argument("--output-image", type=Path, default=None, help="Path to save the SUV image (NIfTI).")
|
|
24
|
+
parser.add_argument("--output-json", type=Path, default=None, help="Path to save the SUV constants (JSON).")
|
|
25
|
+
args = parser.parse_args()
|
|
26
|
+
|
|
27
|
+
if args.output_image is None and args.output_json is None:
|
|
28
|
+
parser.error("at least one of --output-image or --output-json must be given")
|
|
29
|
+
|
|
30
|
+
return args
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def main():
|
|
34
|
+
args = parse_args()
|
|
35
|
+
|
|
36
|
+
pet_img = nib.load(args.pet)
|
|
37
|
+
ct_img = nib.load(args.ct)
|
|
38
|
+
ts_total_img = nib.load(args.totalseg)
|
|
39
|
+
ts_body_img = nib.load(args.bodyseg)
|
|
40
|
+
|
|
41
|
+
suv_img, constants = suv_id(pet_img, ct_img, ts_total_img, ts_body_img)
|
|
42
|
+
|
|
43
|
+
if args.output_image is not None:
|
|
44
|
+
args.output_image.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
nib.save(suv_img, args.output_image)
|
|
46
|
+
|
|
47
|
+
if args.output_json is not None:
|
|
48
|
+
args.output_json.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
with open(args.output_json, "w") as handle:
|
|
50
|
+
json.dump(constants, handle, sort_keys=True, indent=4)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
main()
|
|
Binary file
|