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/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)