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.
@@ -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"]
@@ -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()