ctkit 0.1.0__py3-none-any.whl
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.
- ctkit/__init__.py +144 -0
- ctkit/api.py +358 -0
- ctkit/cli.py +754 -0
- ctkit/config.py +453 -0
- ctkit/constants.py +297 -0
- ctkit/dataset.py +1449 -0
- ctkit/datasets.py +216 -0
- ctkit/features.py +177 -0
- ctkit/image.py +1141 -0
- ctkit/io.py +384 -0
- ctkit/metadata.py +233 -0
- ctkit/py.typed +0 -0
- ctkit/qc.py +482 -0
- ctkit/segmentation.py +367 -0
- ctkit/tcia.py +427 -0
- ctkit/validation.py +187 -0
- ctkit-0.1.0.dist-info/METADATA +168 -0
- ctkit-0.1.0.dist-info/RECORD +22 -0
- ctkit-0.1.0.dist-info/WHEEL +5 -0
- ctkit-0.1.0.dist-info/entry_points.txt +2 -0
- ctkit-0.1.0.dist-info/licenses/LICENSE +24 -0
- ctkit-0.1.0.dist-info/top_level.txt +1 -0
ctkit/segmentation.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Organ and tumor segmentation.
|
|
2
|
+
|
|
3
|
+
TotalSegmentator is a command line tool that reads and writes files, so it is
|
|
4
|
+
run inside a temporary directory that is deleted as soon as the masks are in
|
|
5
|
+
memory. Nothing is left on disk unless `output_dir` names somewhere to keep the
|
|
6
|
+
segmentation files.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import tempfile
|
|
16
|
+
from typing import Iterable, Optional, Sequence
|
|
17
|
+
|
|
18
|
+
import nibabel as nib
|
|
19
|
+
import numpy as np
|
|
20
|
+
import scipy.ndimage as ndi
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
#: The label convention used throughout the package: 0 background, 1 organ,
|
|
25
|
+
#: 2 tumor. Where they overlap, tumor wins.
|
|
26
|
+
BACKGROUND, ORGAN, TUMOR = 0, 1, 2
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TotalSegmentatorNotFound(RuntimeError):
|
|
30
|
+
"""Raised when the TotalSegmentator executable is not available."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def totalsegmentator_available() -> bool:
|
|
34
|
+
return shutil.which("TotalSegmentator") is not None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def segment_organs(
|
|
38
|
+
image: nib.Nifti1Image,
|
|
39
|
+
organs: Sequence[str],
|
|
40
|
+
task: str = "total",
|
|
41
|
+
fast: bool = False,
|
|
42
|
+
remove_small_blobs: bool = True,
|
|
43
|
+
fill_holes: bool = True,
|
|
44
|
+
morphological_closing: bool = True,
|
|
45
|
+
device: Optional[str] = None,
|
|
46
|
+
modality: str = "CT",
|
|
47
|
+
output_dir: Optional[str] = None,
|
|
48
|
+
extra_args: Optional[Sequence[str]] = None,
|
|
49
|
+
) -> nib.Nifti1Image:
|
|
50
|
+
"""Segment `organs` with TotalSegmentator and return their union as a mask.
|
|
51
|
+
|
|
52
|
+
See :func:`segment_organ_components` for the arguments; this is the same
|
|
53
|
+
call with the individual structures merged into a single binary mask.
|
|
54
|
+
"""
|
|
55
|
+
components = segment_organ_components(
|
|
56
|
+
image,
|
|
57
|
+
organs,
|
|
58
|
+
task=task,
|
|
59
|
+
fast=fast,
|
|
60
|
+
remove_small_blobs=remove_small_blobs,
|
|
61
|
+
fill_holes=fill_holes,
|
|
62
|
+
morphological_closing=morphological_closing,
|
|
63
|
+
device=device,
|
|
64
|
+
modality=modality,
|
|
65
|
+
output_dir=output_dir,
|
|
66
|
+
extra_args=extra_args,
|
|
67
|
+
)
|
|
68
|
+
return combine_organ_masks(list(components.values()))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def segment_organ_components(
|
|
72
|
+
image: nib.Nifti1Image,
|
|
73
|
+
organs: Sequence[str],
|
|
74
|
+
task: str = "total",
|
|
75
|
+
fast: bool = False,
|
|
76
|
+
remove_small_blobs: bool = True,
|
|
77
|
+
fill_holes: bool = True,
|
|
78
|
+
morphological_closing: bool = True,
|
|
79
|
+
device: Optional[str] = None,
|
|
80
|
+
modality: str = "CT",
|
|
81
|
+
output_dir: Optional[str] = None,
|
|
82
|
+
extra_args: Optional[Sequence[str]] = None,
|
|
83
|
+
) -> dict:
|
|
84
|
+
"""Segment `organs` and return one binary mask per structure.
|
|
85
|
+
|
|
86
|
+
Parameters
|
|
87
|
+
----------
|
|
88
|
+
image:
|
|
89
|
+
The volume to segment, in memory.
|
|
90
|
+
organs:
|
|
91
|
+
TotalSegmentator structure names, e.g. ``["kidney_left", "kidney_right"]``.
|
|
92
|
+
task:
|
|
93
|
+
TotalSegmentator task. ``"total"`` covers the common organs; MRI input
|
|
94
|
+
is automatically routed to ``"total_mr"``.
|
|
95
|
+
fast:
|
|
96
|
+
Use the 3 mm model. Much faster, coarser boundaries.
|
|
97
|
+
fill_holes, morphological_closing:
|
|
98
|
+
Applied slice-by-slice to each structure, to close the small gaps the
|
|
99
|
+
network leaves at organ boundaries.
|
|
100
|
+
output_dir:
|
|
101
|
+
Where TotalSegmentator writes its NIfTI files. By default a temporary
|
|
102
|
+
directory that is deleted once the masks are in memory; give a path to
|
|
103
|
+
keep them. It is created if it does not exist, and files with the same
|
|
104
|
+
names are overwritten; one that survives the run is reported as
|
|
105
|
+
possibly stale rather than silently read back. The returned masks are
|
|
106
|
+
the same either way — note that they are cleaned in memory, so the
|
|
107
|
+
files on disk are the raw network output.
|
|
108
|
+
|
|
109
|
+
Returns
|
|
110
|
+
-------
|
|
111
|
+
dict
|
|
112
|
+
Structure name to ``uint8`` mask, each sharing the geometry of `image`.
|
|
113
|
+
Structures the model did not produce are omitted.
|
|
114
|
+
"""
|
|
115
|
+
if not organs:
|
|
116
|
+
raise ValueError("`organs` must name at least one structure to segment")
|
|
117
|
+
if not totalsegmentator_available():
|
|
118
|
+
raise TotalSegmentatorNotFound(
|
|
119
|
+
"TotalSegmentator is not on the PATH. Install it with "
|
|
120
|
+
"`pip install TotalSegmentator`, or supply your own mask instead of "
|
|
121
|
+
"calling .segment()."
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if modality.upper() in ("MR", "MRI") and task == "total":
|
|
125
|
+
task = "total_mr"
|
|
126
|
+
|
|
127
|
+
with tempfile.TemporaryDirectory(prefix="ctkit_seg_") as tmp:
|
|
128
|
+
image_path = os.path.join(tmp, "input.nii.gz")
|
|
129
|
+
if output_dir is None:
|
|
130
|
+
segmentation_dir = os.path.join(tmp, "segmentations")
|
|
131
|
+
else:
|
|
132
|
+
segmentation_dir = os.fspath(output_dir)
|
|
133
|
+
os.makedirs(segmentation_dir, exist_ok=True)
|
|
134
|
+
nib.save(image, image_path)
|
|
135
|
+
|
|
136
|
+
# A reused output directory can already hold masks under the names this
|
|
137
|
+
# run writes; anything the run does not touch would be read back as if
|
|
138
|
+
# it belonged to this image, so keep the timestamps to check against.
|
|
139
|
+
existing = {}
|
|
140
|
+
for organ in organs:
|
|
141
|
+
path = os.path.join(segmentation_dir, f"{organ}.nii.gz")
|
|
142
|
+
if os.path.exists(path):
|
|
143
|
+
existing[organ] = os.path.getmtime(path)
|
|
144
|
+
|
|
145
|
+
command = [
|
|
146
|
+
"TotalSegmentator",
|
|
147
|
+
"-i", image_path,
|
|
148
|
+
"-o", segmentation_dir,
|
|
149
|
+
"--task", task,
|
|
150
|
+
]
|
|
151
|
+
# Asking for only the structures we need saves a large amount of time
|
|
152
|
+
# and disk on the `total` task.
|
|
153
|
+
if task == "total" and organs:
|
|
154
|
+
command += ["--roi_subset"] + list(organs)
|
|
155
|
+
if fast:
|
|
156
|
+
command += ["--fast"]
|
|
157
|
+
if remove_small_blobs:
|
|
158
|
+
command += ["--remove_small_blobs"]
|
|
159
|
+
if device:
|
|
160
|
+
command += ["--device", device]
|
|
161
|
+
if extra_args:
|
|
162
|
+
command += list(extra_args)
|
|
163
|
+
|
|
164
|
+
logger.info("Running: %s", " ".join(command))
|
|
165
|
+
completed = subprocess.run(
|
|
166
|
+
command, capture_output=True, text=True, check=False
|
|
167
|
+
)
|
|
168
|
+
if completed.returncode != 0:
|
|
169
|
+
raise RuntimeError(
|
|
170
|
+
"TotalSegmentator failed "
|
|
171
|
+
f"(exit {completed.returncode}).\n{completed.stderr.strip()[-2000:]}"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
if output_dir is not None:
|
|
175
|
+
logger.info("Segmentation files kept in %s", segmentation_dir)
|
|
176
|
+
|
|
177
|
+
masks = {}
|
|
178
|
+
for organ in organs:
|
|
179
|
+
path = os.path.join(segmentation_dir, f"{organ}.nii.gz")
|
|
180
|
+
if not os.path.exists(path):
|
|
181
|
+
logger.warning(
|
|
182
|
+
"TotalSegmentator produced no mask for %r (task=%s). "
|
|
183
|
+
"Check that the structure name is valid for this task.",
|
|
184
|
+
organ, task,
|
|
185
|
+
)
|
|
186
|
+
continue
|
|
187
|
+
if existing.get(organ) == os.path.getmtime(path):
|
|
188
|
+
logger.warning(
|
|
189
|
+
"%s was already in %s and this run did not rewrite it; "
|
|
190
|
+
"it may be left over from a different image.",
|
|
191
|
+
os.path.basename(path), segmentation_dir,
|
|
192
|
+
)
|
|
193
|
+
organ_image = nib.load(path)
|
|
194
|
+
masks[organ] = nib.Nifti1Image(
|
|
195
|
+
np.asanyarray(organ_image.dataobj), organ_image.affine, organ_image.header
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
if not masks:
|
|
199
|
+
raise RuntimeError(
|
|
200
|
+
f"TotalSegmentator produced none of the requested structures: "
|
|
201
|
+
f"{', '.join(organs)}"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
if fill_holes or morphological_closing:
|
|
205
|
+
masks = {
|
|
206
|
+
name: clean_mask(
|
|
207
|
+
mask, fill_holes=fill_holes, morphological_closing=morphological_closing
|
|
208
|
+
)
|
|
209
|
+
for name, mask in masks.items()
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return masks
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def combine_organ_masks(masks: Iterable[nib.Nifti1Image]) -> nib.Nifti1Image:
|
|
216
|
+
"""Union a collection of binary masks that share a geometry."""
|
|
217
|
+
masks = list(masks)
|
|
218
|
+
if not masks:
|
|
219
|
+
raise ValueError("No masks to combine")
|
|
220
|
+
|
|
221
|
+
reference = masks[0]
|
|
222
|
+
for mask in masks[1:]:
|
|
223
|
+
if mask.shape != reference.shape:
|
|
224
|
+
raise ValueError(
|
|
225
|
+
f"Mask shapes differ: {mask.shape} vs {reference.shape}"
|
|
226
|
+
)
|
|
227
|
+
if not np.allclose(mask.affine, reference.affine, atol=1e-4):
|
|
228
|
+
raise ValueError("Mask affines differ; masks are not in the same space")
|
|
229
|
+
|
|
230
|
+
union = np.logical_or.reduce(
|
|
231
|
+
[np.asanyarray(mask.dataobj) > 0 for mask in masks]
|
|
232
|
+
).astype(np.uint8)
|
|
233
|
+
return nib.Nifti1Image(union, reference.affine, reference.header)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def clean_mask(
|
|
237
|
+
mask: nib.Nifti1Image,
|
|
238
|
+
fill_holes: bool = True,
|
|
239
|
+
morphological_closing: bool = True,
|
|
240
|
+
structure_size: int = 3,
|
|
241
|
+
) -> nib.Nifti1Image:
|
|
242
|
+
"""Fill holes and close small gaps, slice by slice in the axial plane."""
|
|
243
|
+
if not fill_holes and not morphological_closing:
|
|
244
|
+
return mask
|
|
245
|
+
|
|
246
|
+
data = np.asanyarray(mask.dataobj) > 0
|
|
247
|
+
if data.ndim == 2:
|
|
248
|
+
data = _clean_slice(data, fill_holes, morphological_closing, structure_size)
|
|
249
|
+
else:
|
|
250
|
+
for index in range(data.shape[2]):
|
|
251
|
+
data[:, :, index] = _clean_slice(
|
|
252
|
+
data[:, :, index], fill_holes, morphological_closing, structure_size
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
return nib.Nifti1Image(data.astype(np.uint8), mask.affine, mask.header)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _clean_slice(
|
|
259
|
+
plane: np.ndarray,
|
|
260
|
+
fill_holes: bool,
|
|
261
|
+
morphological_closing: bool,
|
|
262
|
+
structure_size: int,
|
|
263
|
+
) -> np.ndarray:
|
|
264
|
+
if fill_holes:
|
|
265
|
+
plane = ndi.binary_fill_holes(plane)
|
|
266
|
+
if morphological_closing:
|
|
267
|
+
plane = ndi.binary_closing(
|
|
268
|
+
plane, structure=np.ones((structure_size, structure_size))
|
|
269
|
+
)
|
|
270
|
+
return plane
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def combine_organ_and_tumor(
|
|
274
|
+
organ_mask: nib.Nifti1Image,
|
|
275
|
+
tumor_mask: Optional[nib.Nifti1Image],
|
|
276
|
+
restrict_organs_to_tumor: bool = True,
|
|
277
|
+
organ_components: Optional[dict] = None,
|
|
278
|
+
) -> nib.Nifti1Image:
|
|
279
|
+
"""Merge an organ mask and a tumor mask into one labeled mask.
|
|
280
|
+
|
|
281
|
+
Organ voxels become 1 and tumor voxels 2; where they overlap, tumor wins.
|
|
282
|
+
|
|
283
|
+
When `restrict_organs_to_tumor` is set and `organ_components` maps
|
|
284
|
+
structure names to individual masks, only the structures that actually
|
|
285
|
+
contain tumor are kept — for a paired organ such as the kidneys this
|
|
286
|
+
discards the healthy contralateral side, which would otherwise dominate
|
|
287
|
+
the intensity statistics.
|
|
288
|
+
"""
|
|
289
|
+
if tumor_mask is None:
|
|
290
|
+
organ_data = (np.asanyarray(organ_mask.dataobj) > 0).astype(np.uint8)
|
|
291
|
+
return nib.Nifti1Image(organ_data, organ_mask.affine, organ_mask.header)
|
|
292
|
+
|
|
293
|
+
if tumor_mask.shape != organ_mask.shape:
|
|
294
|
+
raise ValueError(
|
|
295
|
+
f"Tumor mask shape {tumor_mask.shape} does not match organ mask shape "
|
|
296
|
+
f"{organ_mask.shape}. Resample the tumor mask onto the image grid first."
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
tumor = np.asanyarray(tumor_mask.dataobj) > 0
|
|
300
|
+
combined = np.zeros(tumor.shape, dtype=np.uint8)
|
|
301
|
+
|
|
302
|
+
if restrict_organs_to_tumor and organ_components:
|
|
303
|
+
overlapping = {
|
|
304
|
+
name: mask
|
|
305
|
+
for name, mask in organ_components.items()
|
|
306
|
+
if np.any(tumor & (np.asanyarray(mask.dataobj) > 0))
|
|
307
|
+
}
|
|
308
|
+
if not overlapping:
|
|
309
|
+
logger.warning(
|
|
310
|
+
"The tumor mask does not overlap any segmented organ; keeping all organs."
|
|
311
|
+
)
|
|
312
|
+
overlapping = organ_components
|
|
313
|
+
for mask in overlapping.values():
|
|
314
|
+
combined[np.asanyarray(mask.dataobj) > 0] = ORGAN
|
|
315
|
+
else:
|
|
316
|
+
combined[np.asanyarray(organ_mask.dataobj) > 0] = ORGAN
|
|
317
|
+
|
|
318
|
+
combined[tumor] = TUMOR
|
|
319
|
+
return nib.Nifti1Image(combined, organ_mask.affine, organ_mask.header)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def binarize_masked_image(
|
|
323
|
+
image: nib.Nifti1Image,
|
|
324
|
+
label: int = TUMOR,
|
|
325
|
+
tolerance: float = 1e-5,
|
|
326
|
+
) -> nib.Nifti1Image:
|
|
327
|
+
"""Recover a mask from an image that was *masked* rather than labeled.
|
|
328
|
+
|
|
329
|
+
Some archives distribute a "ROI" volume that holds the original intensities
|
|
330
|
+
inside the region of interest and the image minimum (air) everywhere else.
|
|
331
|
+
That is an image, not a label map: thresholding just above the minimum
|
|
332
|
+
turns it back into a mask.
|
|
333
|
+
"""
|
|
334
|
+
data = np.asanyarray(image.dataobj)
|
|
335
|
+
mask = (data > (data.min() + tolerance)).astype(np.uint8) * label
|
|
336
|
+
return nib.Nifti1Image(mask, image.affine, image.header)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def resample_mask_to(
|
|
340
|
+
mask: nib.Nifti1Image, reference: nib.Nifti1Image
|
|
341
|
+
) -> nib.Nifti1Image:
|
|
342
|
+
"""Put `mask` on the voxel grid of `reference` with nearest-neighbor."""
|
|
343
|
+
import SimpleITK as sitk
|
|
344
|
+
|
|
345
|
+
from .io import nifti_to_sitk, sitk_to_nifti
|
|
346
|
+
|
|
347
|
+
resampler = sitk.ResampleImageFilter()
|
|
348
|
+
resampler.SetReferenceImage(nifti_to_sitk(reference))
|
|
349
|
+
resampler.SetInterpolator(sitk.sitkNearestNeighbor)
|
|
350
|
+
resampled = resampler.Execute(nifti_to_sitk(mask))
|
|
351
|
+
out = sitk_to_nifti(resampled)
|
|
352
|
+
return nib.Nifti1Image(
|
|
353
|
+
np.asanyarray(out.dataobj).astype(np.uint8), reference.affine, reference.header
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def dice(mask_a: np.ndarray, mask_b: np.ndarray, label: Optional[int] = None) -> float:
|
|
358
|
+
"""Dice overlap between two masks, optionally restricted to one label."""
|
|
359
|
+
if label is not None:
|
|
360
|
+
first, second = (mask_a == label), (mask_b == label)
|
|
361
|
+
else:
|
|
362
|
+
first, second = (mask_a > 0), (mask_b > 0)
|
|
363
|
+
|
|
364
|
+
total = first.sum() + second.sum()
|
|
365
|
+
if total == 0:
|
|
366
|
+
return 1.0
|
|
367
|
+
return float(2.0 * np.logical_and(first, second).sum() / total)
|