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/image.py
ADDED
|
@@ -0,0 +1,1141 @@
|
|
|
1
|
+
"""The :class:`RadiologyImage` class: one scan, one optional mask, in memory.
|
|
2
|
+
|
|
3
|
+
Every processing step is a method that transforms the image (and its mask, when
|
|
4
|
+
the step affects geometry) and returns ``self``, so a protocol reads as a
|
|
5
|
+
chain::
|
|
6
|
+
|
|
7
|
+
RadiologyImage("case/imaging.nii.gz", mask="case/segmentation.nii.gz") \\
|
|
8
|
+
.orient().clip(-200, 300).resample((0.8, 0.8, 3.0)) \\
|
|
9
|
+
.apply_mask().standardize_size(185, 185, 75) \\
|
|
10
|
+
.save("out/case.nii.gz")
|
|
11
|
+
|
|
12
|
+
Nothing touches the disk until :meth:`RadiologyImage.save`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import copy as copy_module
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
from typing import Any, Optional, Sequence, Union
|
|
21
|
+
|
|
22
|
+
import nibabel as nib
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
from . import qc as qc_module
|
|
26
|
+
from . import segmentation as seg
|
|
27
|
+
from .config import ProcessingConfig
|
|
28
|
+
from .io import (
|
|
29
|
+
ImageLike,
|
|
30
|
+
infer_series_id,
|
|
31
|
+
load_image,
|
|
32
|
+
nifti_to_sitk,
|
|
33
|
+
resolve_output_path,
|
|
34
|
+
save_image,
|
|
35
|
+
sitk_to_nifti,
|
|
36
|
+
)
|
|
37
|
+
from .validation import (
|
|
38
|
+
Integer,
|
|
39
|
+
Interpolator,
|
|
40
|
+
Labels,
|
|
41
|
+
NormalizationMethod,
|
|
42
|
+
Number,
|
|
43
|
+
OutputFormat,
|
|
44
|
+
PathArg,
|
|
45
|
+
QCLevel,
|
|
46
|
+
QCPreset,
|
|
47
|
+
SliceMode,
|
|
48
|
+
Spacing,
|
|
49
|
+
validate_class,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
logger = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@validate_class
|
|
56
|
+
class RadiologyImage:
|
|
57
|
+
"""A single radiology image, optionally paired with a segmentation mask.
|
|
58
|
+
|
|
59
|
+
Parameters
|
|
60
|
+
----------
|
|
61
|
+
image:
|
|
62
|
+
Path to a NIfTI file, a DICOM directory or zip, a ``.npy`` array, or an
|
|
63
|
+
already-loaded ``Nifti1Image`` / ``SimpleITK.Image`` / ``ndarray``.
|
|
64
|
+
mask:
|
|
65
|
+
Optional segmentation in any of the same forms. By convention 1 is
|
|
66
|
+
organ and 2 is tumor, but any labeling works as long as you pass the
|
|
67
|
+
matching ``labels`` to the steps that use it.
|
|
68
|
+
series_id:
|
|
69
|
+
Identifier used in reports and output filenames. Inferred from the path
|
|
70
|
+
when omitted.
|
|
71
|
+
metadata:
|
|
72
|
+
Free-form dictionary carried along with the image (a metadata row, the
|
|
73
|
+
modality, acquisition parameters).
|
|
74
|
+
masked_roi:
|
|
75
|
+
Set when `mask` is not a label map but an image that keeps the original
|
|
76
|
+
intensities inside the ROI and the image minimum outside it. It is
|
|
77
|
+
converted to a real mask on load.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
image: ImageLike,
|
|
83
|
+
mask: Optional[ImageLike] = None,
|
|
84
|
+
series_id: Optional[str] = None,
|
|
85
|
+
metadata: Optional[dict] = None,
|
|
86
|
+
masked_roi: bool = False,
|
|
87
|
+
spacing: Optional[Sequence[float]] = None,
|
|
88
|
+
affine: Optional[np.ndarray] = None,
|
|
89
|
+
lazy: bool = False,
|
|
90
|
+
) -> None:
|
|
91
|
+
self.source = image if isinstance(image, (str, os.PathLike)) else None
|
|
92
|
+
self.mask_source = mask if isinstance(mask, (str, os.PathLike)) else None
|
|
93
|
+
self.series_id = series_id or infer_series_id(image)
|
|
94
|
+
self.metadata: dict = dict(metadata or {})
|
|
95
|
+
self.history: list = []
|
|
96
|
+
self._masked_roi = masked_roi
|
|
97
|
+
self._spacing_hint = spacing
|
|
98
|
+
self._affine_hint = affine
|
|
99
|
+
|
|
100
|
+
if lazy and self.source is not None:
|
|
101
|
+
self._image: Optional[nib.Nifti1Image] = None
|
|
102
|
+
self._mask: Optional[nib.Nifti1Image] = None
|
|
103
|
+
self._loaded = False
|
|
104
|
+
else:
|
|
105
|
+
self._image = load_image(image, affine=affine, spacing=spacing)
|
|
106
|
+
self._mask = self._load_mask(mask)
|
|
107
|
+
self._loaded = True
|
|
108
|
+
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
# loading
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
def _load_mask(self, mask: Optional[ImageLike]) -> Optional[nib.Nifti1Image]:
|
|
113
|
+
if mask is None:
|
|
114
|
+
return None
|
|
115
|
+
loaded = load_image(mask, affine=self._affine_hint, spacing=self._spacing_hint)
|
|
116
|
+
if self._masked_roi:
|
|
117
|
+
loaded = seg.binarize_masked_image(loaded, label=seg.TUMOR)
|
|
118
|
+
return loaded
|
|
119
|
+
|
|
120
|
+
def load(self) -> "RadiologyImage":
|
|
121
|
+
"""Materialize a lazily-constructed image. Idempotent."""
|
|
122
|
+
if not self._loaded:
|
|
123
|
+
self._image = load_image(
|
|
124
|
+
self.source, affine=self._affine_hint, spacing=self._spacing_hint
|
|
125
|
+
)
|
|
126
|
+
self._mask = self._load_mask(self.mask_source)
|
|
127
|
+
self._loaded = True
|
|
128
|
+
return self
|
|
129
|
+
|
|
130
|
+
def unload(self) -> "RadiologyImage":
|
|
131
|
+
"""Drop pixel data, keeping identity and history. Only for path-backed images."""
|
|
132
|
+
if self.source is None:
|
|
133
|
+
raise ValueError(
|
|
134
|
+
"Cannot unload an image that was not created from a path; its data "
|
|
135
|
+
"would be lost. Save it first."
|
|
136
|
+
)
|
|
137
|
+
self._image = None
|
|
138
|
+
self._mask = None
|
|
139
|
+
self._loaded = False
|
|
140
|
+
return self
|
|
141
|
+
|
|
142
|
+
# ------------------------------------------------------------------
|
|
143
|
+
# data access
|
|
144
|
+
# ------------------------------------------------------------------
|
|
145
|
+
@property
|
|
146
|
+
def image(self) -> nib.Nifti1Image:
|
|
147
|
+
"""The image as a :class:`nibabel.Nifti1Image`."""
|
|
148
|
+
self.load()
|
|
149
|
+
assert self._image is not None
|
|
150
|
+
return self._image
|
|
151
|
+
|
|
152
|
+
@image.setter
|
|
153
|
+
def image(self, value: ImageLike) -> None:
|
|
154
|
+
self._image = load_image(value)
|
|
155
|
+
self._loaded = True
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def mask(self) -> Optional[nib.Nifti1Image]:
|
|
159
|
+
"""The segmentation mask, or ``None``."""
|
|
160
|
+
self.load()
|
|
161
|
+
return self._mask
|
|
162
|
+
|
|
163
|
+
@mask.setter
|
|
164
|
+
def mask(self, value: Optional[ImageLike]) -> None:
|
|
165
|
+
self.load()
|
|
166
|
+
self._mask = None if value is None else load_image(value)
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def array(self) -> np.ndarray:
|
|
170
|
+
"""Voxel data as a NumPy array."""
|
|
171
|
+
return np.asanyarray(self.image.dataobj)
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def mask_array(self) -> Optional[np.ndarray]:
|
|
175
|
+
if self.mask is None:
|
|
176
|
+
return None
|
|
177
|
+
return np.asanyarray(self.mask.dataobj)
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def affine(self) -> np.ndarray:
|
|
181
|
+
return self.image.affine
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def shape(self) -> tuple:
|
|
185
|
+
return tuple(int(size) for size in self.image.shape)
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def ndim(self) -> int:
|
|
189
|
+
return len(self.image.shape)
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def spacing(self) -> tuple:
|
|
193
|
+
"""Voxel size in mm along each axis."""
|
|
194
|
+
return tuple(float(zoom) for zoom in self.image.header.get_zooms()[: self.ndim])
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def orientation(self) -> str:
|
|
198
|
+
"""Anatomical axis codes of the storage order, e.g. ``"RAS"``."""
|
|
199
|
+
return "".join(nib.orientations.aff2axcodes(self.affine))
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def has_mask(self) -> bool:
|
|
203
|
+
return self.mask is not None
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def labels(self) -> list:
|
|
207
|
+
"""Sorted non-zero label values present in the mask."""
|
|
208
|
+
if self.mask is None:
|
|
209
|
+
return []
|
|
210
|
+
values = np.unique(self.mask_array)
|
|
211
|
+
return [int(value) for value in values if value != 0]
|
|
212
|
+
|
|
213
|
+
# ------------------------------------------------------------------
|
|
214
|
+
# conversions
|
|
215
|
+
# ------------------------------------------------------------------
|
|
216
|
+
def to_numpy(self, with_mask: bool = False):
|
|
217
|
+
"""Return the voxel array, or ``(image, mask)`` when `with_mask`."""
|
|
218
|
+
if with_mask:
|
|
219
|
+
return self.array, self.mask_array
|
|
220
|
+
return self.array
|
|
221
|
+
|
|
222
|
+
def to_nifti(self) -> nib.Nifti1Image:
|
|
223
|
+
return self.image
|
|
224
|
+
|
|
225
|
+
def to_sitk(self):
|
|
226
|
+
return nifti_to_sitk(self.image)
|
|
227
|
+
|
|
228
|
+
def copy(self) -> "RadiologyImage":
|
|
229
|
+
"""Deep copy, so chains can branch without interfering."""
|
|
230
|
+
clone = copy_module.copy(self)
|
|
231
|
+
clone.metadata = dict(self.metadata)
|
|
232
|
+
clone.history = list(self.history)
|
|
233
|
+
if self._loaded:
|
|
234
|
+
clone._image = _copy_nifti(self._image)
|
|
235
|
+
clone._mask = _copy_nifti(self._mask)
|
|
236
|
+
return clone
|
|
237
|
+
|
|
238
|
+
# ------------------------------------------------------------------
|
|
239
|
+
# processing steps
|
|
240
|
+
# ------------------------------------------------------------------
|
|
241
|
+
def orient(self, target: str = "RAS") -> "RadiologyImage":
|
|
242
|
+
"""Reorient to a canonical anatomical axis order (default RAS).
|
|
243
|
+
|
|
244
|
+
Archives are inconsistent about storage order, so two scans of the same
|
|
245
|
+
anatomy can be mirrored or transposed relative to each other. This puts
|
|
246
|
+
every image in the same frame, which any downstream model or feature
|
|
247
|
+
will otherwise have to learn to undo.
|
|
248
|
+
"""
|
|
249
|
+
target = target.upper()
|
|
250
|
+
if self.orientation == target:
|
|
251
|
+
return self._record("orient", target=target, changed=False)
|
|
252
|
+
|
|
253
|
+
if target == "RAS":
|
|
254
|
+
self._image = nib.as_closest_canonical(self.image)
|
|
255
|
+
if self._mask is not None:
|
|
256
|
+
self._mask = nib.as_closest_canonical(self._mask)
|
|
257
|
+
else:
|
|
258
|
+
self._image = _reorient_to(self.image, target)
|
|
259
|
+
if self._mask is not None:
|
|
260
|
+
self._mask = _reorient_to(self._mask, target)
|
|
261
|
+
|
|
262
|
+
return self._record("orient", target=target, changed=True)
|
|
263
|
+
|
|
264
|
+
def clip(
|
|
265
|
+
self,
|
|
266
|
+
min_value: Optional[Number] = -200,
|
|
267
|
+
max_value: Optional[Number] = 300,
|
|
268
|
+
) -> "RadiologyImage":
|
|
269
|
+
"""Clamp intensities to a window, e.g. (-200, 300) HU for soft tissue.
|
|
270
|
+
|
|
271
|
+
Clipping discards contrast the tissue of interest does not occupy, so
|
|
272
|
+
the remaining range is spent where it matters. It also caps the effect
|
|
273
|
+
of metal artifacts and out-of-field air.
|
|
274
|
+
"""
|
|
275
|
+
if min_value is None and max_value is None:
|
|
276
|
+
raise ValueError("clip() needs min_value and/or max_value")
|
|
277
|
+
|
|
278
|
+
data = self.array.astype(_working_dtype(self.array.dtype), copy=True)
|
|
279
|
+
np.clip(data, min_value, max_value, out=data)
|
|
280
|
+
self._image = _with_data(self.image, data)
|
|
281
|
+
return self._record("clip", min_value=min_value, max_value=max_value)
|
|
282
|
+
|
|
283
|
+
def resample(
|
|
284
|
+
self,
|
|
285
|
+
spacing: Spacing = (0.8, 0.8, 3.0),
|
|
286
|
+
interpolator: Interpolator = "linear",
|
|
287
|
+
) -> "RadiologyImage":
|
|
288
|
+
"""Resample onto a fixed voxel size in mm; ``None`` keeps an axis as is.
|
|
289
|
+
|
|
290
|
+
CT series vary widely in slice thickness and in-plane resolution. Until
|
|
291
|
+
they share a voxel grid, a millimeter of anatomy means a different
|
|
292
|
+
number of voxels in each scan, and size or texture features are not
|
|
293
|
+
comparable across a cohort.
|
|
294
|
+
|
|
295
|
+
The mask, when present, is resampled with nearest-neighbor so its
|
|
296
|
+
labels stay integral.
|
|
297
|
+
"""
|
|
298
|
+
import SimpleITK as sitk
|
|
299
|
+
|
|
300
|
+
spacing = list(spacing)
|
|
301
|
+
if len(spacing) != self.ndim:
|
|
302
|
+
spacing = (spacing + [None, None, None])[: self.ndim]
|
|
303
|
+
|
|
304
|
+
current = self.spacing
|
|
305
|
+
target = [
|
|
306
|
+
float(current[axis]) if spacing[axis] is None else float(spacing[axis])
|
|
307
|
+
for axis in range(self.ndim)
|
|
308
|
+
]
|
|
309
|
+
if np.allclose(target, current, atol=1e-6):
|
|
310
|
+
return self._record("resample", spacing=tuple(target), changed=False)
|
|
311
|
+
|
|
312
|
+
interpolators = {
|
|
313
|
+
"linear": sitk.sitkLinear,
|
|
314
|
+
"nearest": sitk.sitkNearestNeighbor,
|
|
315
|
+
"bspline": sitk.sitkBSpline,
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
self._image = _resample_nifti(self.image, target, interpolators[interpolator])
|
|
319
|
+
if self._mask is not None:
|
|
320
|
+
resampled_mask = _resample_nifti(self._mask, target, sitk.sitkNearestNeighbor)
|
|
321
|
+
self._mask = _as_label_map(resampled_mask)
|
|
322
|
+
|
|
323
|
+
return self._record("resample", spacing=tuple(target), changed=True)
|
|
324
|
+
|
|
325
|
+
def segment(
|
|
326
|
+
self,
|
|
327
|
+
organs: Optional[Sequence[str]] = None,
|
|
328
|
+
task: str = "total",
|
|
329
|
+
tumor_mask: Optional[ImageLike] = None,
|
|
330
|
+
restrict_organs_to_tumor: bool = True,
|
|
331
|
+
fast: bool = False,
|
|
332
|
+
remove_small_blobs: bool = True,
|
|
333
|
+
fill_holes: bool = True,
|
|
334
|
+
morphological_closing: bool = True,
|
|
335
|
+
device: Optional[str] = None,
|
|
336
|
+
output_dir: Optional[PathArg] = None,
|
|
337
|
+
replace: bool = False,
|
|
338
|
+
) -> "RadiologyImage":
|
|
339
|
+
"""Segment organs with TotalSegmentator and set :attr:`mask`.
|
|
340
|
+
|
|
341
|
+
Organs are labeled 1 and tumor 2. An existing mask (or `tumor_mask`)
|
|
342
|
+
is treated as the tumor and merged in; with `restrict_organs_to_tumor`,
|
|
343
|
+
only structures containing tumor are kept, so the healthy contralateral
|
|
344
|
+
organ does not enter the ROI.
|
|
345
|
+
|
|
346
|
+
Requires the ``TotalSegmentator`` package. Its files are written to a
|
|
347
|
+
temporary directory and removed once the masks are in memory; pass
|
|
348
|
+
`output_dir` to write them somewhere that is kept instead.
|
|
349
|
+
"""
|
|
350
|
+
if organs is None:
|
|
351
|
+
organs = _organs_from_metadata(self.metadata)
|
|
352
|
+
if not organs:
|
|
353
|
+
raise ValueError(
|
|
354
|
+
"segment() needs `organs` (TotalSegmentator structure names). "
|
|
355
|
+
"For a curated per-collection list use "
|
|
356
|
+
"ProcessingConfig.for_dataset('tcga-kirc').organs"
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
existing_tumor = None
|
|
360
|
+
if not replace:
|
|
361
|
+
if tumor_mask is not None:
|
|
362
|
+
existing_tumor = load_image(tumor_mask)
|
|
363
|
+
elif self.mask is not None:
|
|
364
|
+
existing_tumor = self.mask
|
|
365
|
+
|
|
366
|
+
components = seg.segment_organ_components(
|
|
367
|
+
self.image,
|
|
368
|
+
organs,
|
|
369
|
+
task=task,
|
|
370
|
+
fast=fast,
|
|
371
|
+
remove_small_blobs=remove_small_blobs,
|
|
372
|
+
fill_holes=fill_holes,
|
|
373
|
+
morphological_closing=morphological_closing,
|
|
374
|
+
device=device,
|
|
375
|
+
modality=str(self.metadata.get("Modality", self.metadata.get("modality", "CT"))),
|
|
376
|
+
output_dir=output_dir,
|
|
377
|
+
)
|
|
378
|
+
organ_mask = seg.combine_organ_masks(list(components.values()))
|
|
379
|
+
|
|
380
|
+
if existing_tumor is not None and existing_tumor.shape != organ_mask.shape:
|
|
381
|
+
existing_tumor = seg.resample_mask_to(existing_tumor, self.image)
|
|
382
|
+
|
|
383
|
+
self._mask = seg.combine_organ_and_tumor(
|
|
384
|
+
organ_mask,
|
|
385
|
+
existing_tumor,
|
|
386
|
+
restrict_organs_to_tumor=restrict_organs_to_tumor,
|
|
387
|
+
organ_components=components,
|
|
388
|
+
)
|
|
389
|
+
self.metadata["segmented_organs"] = list(components)
|
|
390
|
+
return self._record(
|
|
391
|
+
"segment", organs=list(components), task=task, has_tumor=existing_tumor is not None
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
def select_slice(
|
|
395
|
+
self,
|
|
396
|
+
mode: SliceMode = "mask",
|
|
397
|
+
index: Optional[Integer] = None,
|
|
398
|
+
label: Optional[Labels] = None,
|
|
399
|
+
keepdims: bool = False,
|
|
400
|
+
) -> "RadiologyImage":
|
|
401
|
+
"""Reduce a volume to one axial slice — how a 3D series becomes a 2D example.
|
|
402
|
+
|
|
403
|
+
Two ways to choose it:
|
|
404
|
+
|
|
405
|
+
``mode="mask"`` (the default)
|
|
406
|
+
The slice holding the most mask. Needs a mask. `label` says which
|
|
407
|
+
mask value counts; leave it out and the mask's only non-zero value
|
|
408
|
+
is used, which covers a binary mask. A mask with several labels is
|
|
409
|
+
ambiguous, so there `label` has to be given — ``2`` for the tumor
|
|
410
|
+
in this package's organ=1 / tumor=2 convention, ``[1, 2]`` for
|
|
411
|
+
both. If the requested label is absent, slice 0 is kept and the
|
|
412
|
+
recorded count is zero, rather than failing.
|
|
413
|
+
|
|
414
|
+
``mode="index"``
|
|
415
|
+
The slice you name in `index`. Negative indices count from the end.
|
|
416
|
+
No mask needed.
|
|
417
|
+
|
|
418
|
+
`keepdims` keeps the third axis with length 1 instead of dropping it.
|
|
419
|
+
"""
|
|
420
|
+
if self.ndim < 3:
|
|
421
|
+
return self._record("select_slice", mode=mode, changed=False)
|
|
422
|
+
|
|
423
|
+
n_slices = self.shape[2]
|
|
424
|
+
count: Optional[int] = None
|
|
425
|
+
|
|
426
|
+
if mode == "index":
|
|
427
|
+
if index is None:
|
|
428
|
+
raise ValueError(
|
|
429
|
+
"select_slice(mode='index') needs index=<slice number>. Use "
|
|
430
|
+
"mode='mask' to pick the slice with the most mask instead."
|
|
431
|
+
)
|
|
432
|
+
chosen = int(index)
|
|
433
|
+
if chosen < 0:
|
|
434
|
+
chosen += n_slices
|
|
435
|
+
if not 0 <= chosen < n_slices:
|
|
436
|
+
raise IndexError(
|
|
437
|
+
f"{self.series_id or 'image'}: slice index {index} is out of range "
|
|
438
|
+
f"for a volume with {n_slices} slices."
|
|
439
|
+
)
|
|
440
|
+
else:
|
|
441
|
+
if self.mask is None:
|
|
442
|
+
raise ValueError(
|
|
443
|
+
"select_slice(mode='mask') needs a mask to measure. Call "
|
|
444
|
+
".segment() first, pass mask= when constructing the image, or "
|
|
445
|
+
"use mode='index' to pick a slice number directly."
|
|
446
|
+
)
|
|
447
|
+
mask_data = self.mask_array
|
|
448
|
+
label = self._resolve_mask_label(label)
|
|
449
|
+
if label is None: # an empty mask: nothing to measure
|
|
450
|
+
chosen, count = 0, 0
|
|
451
|
+
else:
|
|
452
|
+
wanted = (
|
|
453
|
+
np.isin(mask_data, list(label))
|
|
454
|
+
if isinstance(label, (list, tuple, set, np.ndarray))
|
|
455
|
+
else (mask_data == label)
|
|
456
|
+
)
|
|
457
|
+
area_per_slice = wanted.sum(axis=(0, 1))
|
|
458
|
+
chosen = int(np.argmax(area_per_slice))
|
|
459
|
+
count = int(area_per_slice[chosen])
|
|
460
|
+
if count == 0:
|
|
461
|
+
logger.warning(
|
|
462
|
+
"%s: label %s is absent from the mask; keeping slice 0.",
|
|
463
|
+
self.series_id or "image", _label_text(label),
|
|
464
|
+
)
|
|
465
|
+
chosen = 0
|
|
466
|
+
|
|
467
|
+
self.metadata["selected_slice"] = chosen
|
|
468
|
+
if mode == "mask":
|
|
469
|
+
self.metadata["selected_slice_label"] = label
|
|
470
|
+
self.metadata["selected_slice_mask_voxels"] = count
|
|
471
|
+
|
|
472
|
+
selector = (slice(None), slice(None), slice(chosen, chosen + 1))
|
|
473
|
+
image_slice = self.array[selector]
|
|
474
|
+
if not keepdims:
|
|
475
|
+
image_slice = image_slice[:, :, 0]
|
|
476
|
+
|
|
477
|
+
affine = _shift_affine(self.affine, (0, 0, chosen))
|
|
478
|
+
self._image = nib.Nifti1Image(image_slice, affine, self.image.header)
|
|
479
|
+
if self._mask is not None:
|
|
480
|
+
mask_slice = self.mask_array[selector]
|
|
481
|
+
if not keepdims:
|
|
482
|
+
mask_slice = mask_slice[:, :, 0]
|
|
483
|
+
self._mask = nib.Nifti1Image(mask_slice, affine, self.mask.header)
|
|
484
|
+
|
|
485
|
+
return self._record(
|
|
486
|
+
"select_slice", mode=mode, index=chosen, label=label, n_voxels=count
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
def _resolve_mask_label(
|
|
490
|
+
self, label: Optional[Union[int, Sequence[int]]]
|
|
491
|
+
) -> Optional[Union[int, Sequence[int]]]:
|
|
492
|
+
"""Default to the mask's only non-zero value; refuse to guess past that.
|
|
493
|
+
|
|
494
|
+
Returns ``None`` when the mask is empty, so there is nothing to measure.
|
|
495
|
+
"""
|
|
496
|
+
if label is not None:
|
|
497
|
+
return label
|
|
498
|
+
|
|
499
|
+
present = self.labels
|
|
500
|
+
if len(present) == 1:
|
|
501
|
+
return present[0]
|
|
502
|
+
if not present:
|
|
503
|
+
logger.warning(
|
|
504
|
+
"%s: the mask is empty, so there is no label to select a slice by; "
|
|
505
|
+
"keeping slice 0.",
|
|
506
|
+
self.series_id or "image",
|
|
507
|
+
)
|
|
508
|
+
return None
|
|
509
|
+
raise ValueError(
|
|
510
|
+
f"{self.series_id or 'image'}: the mask holds several labels "
|
|
511
|
+
f"({', '.join(str(value) for value in present)}), so which one to "
|
|
512
|
+
"measure is ambiguous. Pass label= (in this package's convention 1 is "
|
|
513
|
+
"organ and 2 is tumor; a list measures several together)."
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
def apply_mask(
|
|
517
|
+
self,
|
|
518
|
+
labels: Optional[Labels] = None,
|
|
519
|
+
crop: bool = True,
|
|
520
|
+
padding: Integer = 5,
|
|
521
|
+
fill_value: Optional[Number] = None,
|
|
522
|
+
) -> "RadiologyImage":
|
|
523
|
+
"""Blank everything outside the mask, then crop to what is left.
|
|
524
|
+
|
|
525
|
+
`labels` selects which mask values are kept (``None`` means every
|
|
526
|
+
non-zero value). Voxels outside become `fill_value`, defaulting to the
|
|
527
|
+
image minimum so the padding reads as air rather than as tissue.
|
|
528
|
+
|
|
529
|
+
Cropping to the ROI bounding box (plus `padding` voxels) is what makes
|
|
530
|
+
the output small enough to hold a cohort in memory.
|
|
531
|
+
"""
|
|
532
|
+
if self.mask is None:
|
|
533
|
+
raise ValueError(
|
|
534
|
+
"apply_mask() needs a mask. Call .segment() first or pass mask= "
|
|
535
|
+
"when constructing the image."
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
data = self.array
|
|
539
|
+
mask_data = np.rint(self.mask_array).astype(np.int32)
|
|
540
|
+
if data.shape != mask_data.shape:
|
|
541
|
+
raise ValueError(
|
|
542
|
+
f"Image shape {data.shape} does not match mask shape {mask_data.shape}. "
|
|
543
|
+
"Resample them onto a common grid first."
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
if fill_value is None:
|
|
547
|
+
fill_value = float(data.min())
|
|
548
|
+
|
|
549
|
+
if labels is None:
|
|
550
|
+
keep = mask_data > 0
|
|
551
|
+
elif isinstance(labels, (list, tuple, set, np.ndarray)):
|
|
552
|
+
keep = np.isin(mask_data, list(labels))
|
|
553
|
+
else:
|
|
554
|
+
keep = mask_data == labels
|
|
555
|
+
|
|
556
|
+
if not keep.any():
|
|
557
|
+
logger.warning(
|
|
558
|
+
"%s: mask selects no voxels for labels=%s; the output will be empty.",
|
|
559
|
+
self.series_id or "image", labels,
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
masked = np.where(keep, data, fill_value).astype(
|
|
563
|
+
_working_dtype(data.dtype), copy=False
|
|
564
|
+
)
|
|
565
|
+
self._image = _with_data(self.image, masked)
|
|
566
|
+
|
|
567
|
+
if crop:
|
|
568
|
+
bbox = _bounding_box(masked, threshold=fill_value, padding=padding)
|
|
569
|
+
if bbox is None:
|
|
570
|
+
logger.warning(
|
|
571
|
+
"%s: nothing above the fill value after masking; skipping the crop.",
|
|
572
|
+
self.series_id or "image",
|
|
573
|
+
)
|
|
574
|
+
else:
|
|
575
|
+
self._image = _crop(self._image, bbox)
|
|
576
|
+
self._mask = _crop(self._mask, bbox)
|
|
577
|
+
|
|
578
|
+
return self._record("apply_mask", labels=labels, crop=crop, padding=padding)
|
|
579
|
+
|
|
580
|
+
def crop_to_content(
|
|
581
|
+
self, threshold: Optional[Number] = None, padding: Integer = 5
|
|
582
|
+
) -> "RadiologyImage":
|
|
583
|
+
"""Crop to the bounding box of voxels above `threshold` (default: the minimum)."""
|
|
584
|
+
data = self.array
|
|
585
|
+
bbox = _bounding_box(data, threshold=threshold, padding=padding)
|
|
586
|
+
if bbox is None:
|
|
587
|
+
logger.warning("%s: image is uniform; skipping the crop.", self.series_id or "image")
|
|
588
|
+
return self._record("crop_to_content", changed=False)
|
|
589
|
+
self._image = _crop(self.image, bbox)
|
|
590
|
+
if self._mask is not None:
|
|
591
|
+
self._mask = _crop(self._mask, bbox)
|
|
592
|
+
return self._record("crop_to_content", threshold=threshold, padding=padding)
|
|
593
|
+
|
|
594
|
+
def standardize_size(
|
|
595
|
+
self,
|
|
596
|
+
x: Optional[Integer] = None,
|
|
597
|
+
y: Optional[Integer] = None,
|
|
598
|
+
z: Optional[Integer] = None,
|
|
599
|
+
fill_value: Optional[Number] = None,
|
|
600
|
+
mask_fill_value: Number = 0,
|
|
601
|
+
) -> "RadiologyImage":
|
|
602
|
+
"""Center-crop or center-pad to a fixed array shape.
|
|
603
|
+
|
|
604
|
+
Models that take fixed-size tensors need every case to have the same
|
|
605
|
+
shape. Cropping and padding about the center preserves voxel size (so
|
|
606
|
+
the anatomy stays the same physical scale), unlike rescaling.
|
|
607
|
+
``None`` leaves an axis untouched.
|
|
608
|
+
"""
|
|
609
|
+
dims = [x, y, z][: self.ndim]
|
|
610
|
+
if all(dim is None for dim in dims):
|
|
611
|
+
return self._record("standardize_size", changed=False)
|
|
612
|
+
|
|
613
|
+
if fill_value is None:
|
|
614
|
+
fill_value = float(self.array.min())
|
|
615
|
+
|
|
616
|
+
self._image = _crop_or_pad(self.image, dims, fill_value)
|
|
617
|
+
if self._mask is not None:
|
|
618
|
+
self._mask = _crop_or_pad(self._mask, dims, mask_fill_value)
|
|
619
|
+
return self._record("standardize_size", shape=self.shape)
|
|
620
|
+
|
|
621
|
+
def normalize(
|
|
622
|
+
self,
|
|
623
|
+
method: NormalizationMethod = "volume",
|
|
624
|
+
mean: Optional[Number] = None,
|
|
625
|
+
std: Optional[Number] = None,
|
|
626
|
+
within_mask: bool = False,
|
|
627
|
+
) -> "RadiologyImage":
|
|
628
|
+
"""Z-score the intensities.
|
|
629
|
+
|
|
630
|
+
``method="volume"`` uses this volume's own mean and standard deviation.
|
|
631
|
+
``method="dataset"`` requires `mean` and `std` computed over the whole
|
|
632
|
+
cohort — use :meth:`Dataset.intensity_statistics`, which keeps every
|
|
633
|
+
scan on one intensity scale so a model cannot key on per-scan offsets.
|
|
634
|
+
"""
|
|
635
|
+
data = self.array.astype(np.float32, copy=True)
|
|
636
|
+
|
|
637
|
+
if method == "dataset":
|
|
638
|
+
if mean is None or std is None:
|
|
639
|
+
raise ValueError(
|
|
640
|
+
"method='dataset' needs mean and std pooled over the cohort. "
|
|
641
|
+
"Get them from Dataset.intensity_statistics()."
|
|
642
|
+
)
|
|
643
|
+
else:
|
|
644
|
+
sample = data[self.mask_array > 0] if (within_mask and self.mask is not None) else data
|
|
645
|
+
mean = float(sample.mean())
|
|
646
|
+
std = float(sample.std())
|
|
647
|
+
|
|
648
|
+
if not std:
|
|
649
|
+
raise ValueError(
|
|
650
|
+
f"{self.series_id or 'image'}: standard deviation is zero, so the "
|
|
651
|
+
"image is uniform and cannot be z-scored."
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
data -= float(mean)
|
|
655
|
+
data /= float(std)
|
|
656
|
+
self._image = _with_data(self.image, data)
|
|
657
|
+
return self._record("normalize", method=method, mean=float(mean), std=float(std))
|
|
658
|
+
|
|
659
|
+
# ------------------------------------------------------------------
|
|
660
|
+
# whole pipeline
|
|
661
|
+
# ------------------------------------------------------------------
|
|
662
|
+
def process(
|
|
663
|
+
self,
|
|
664
|
+
config: Optional[Any] = None,
|
|
665
|
+
**overrides: Any,
|
|
666
|
+
) -> "RadiologyImage":
|
|
667
|
+
"""Run the full protocol described by `config`, in order.
|
|
668
|
+
|
|
669
|
+
`config` is a :class:`ProcessingConfig`, the name of a collection whose
|
|
670
|
+
curated protocol to use (``image.process("tcga-kirc")``), or a path to
|
|
671
|
+
a saved YAML protocol. Steps whose inputs are missing are skipped with
|
|
672
|
+
a warning rather than raising, so one odd series does not stop a
|
|
673
|
+
cohort. Pass keyword arguments to override individual config fields.
|
|
674
|
+
"""
|
|
675
|
+
config = ProcessingConfig.resolve(config, **overrides)
|
|
676
|
+
config.validate()
|
|
677
|
+
|
|
678
|
+
if config.orient:
|
|
679
|
+
self.orient(config.target_orientation)
|
|
680
|
+
|
|
681
|
+
if config.segment:
|
|
682
|
+
# One subdirectory per series, so that a cohort sharing a config
|
|
683
|
+
# does not have every image overwrite the same filenames.
|
|
684
|
+
segmentation_dir = (
|
|
685
|
+
None
|
|
686
|
+
if config.segmentation_dir is None
|
|
687
|
+
else os.path.join(config.segmentation_dir, self.series_id or "image")
|
|
688
|
+
)
|
|
689
|
+
self.segment(
|
|
690
|
+
organs=config.organs,
|
|
691
|
+
task=config.totalsegmentator_task,
|
|
692
|
+
restrict_organs_to_tumor=config.restrict_organs_to_tumor,
|
|
693
|
+
fast=config.fast_segmentation,
|
|
694
|
+
remove_small_blobs=config.remove_small_blobs,
|
|
695
|
+
fill_holes=config.fill_holes,
|
|
696
|
+
morphological_closing=config.morphological_closing,
|
|
697
|
+
device=config.device,
|
|
698
|
+
output_dir=segmentation_dir,
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
if config.clip:
|
|
702
|
+
self.clip(config.clip_min, config.clip_max)
|
|
703
|
+
|
|
704
|
+
if config.crop_to_content:
|
|
705
|
+
if config.resolved_crop_threshold is None:
|
|
706
|
+
logger.warning(
|
|
707
|
+
"%s: crop_to_content with no threshold and no clipping crops "
|
|
708
|
+
"against the image minimum, which on raw HU is noise rather "
|
|
709
|
+
"than air; set crop_content_threshold to a value in HU.",
|
|
710
|
+
self.series_id or "image",
|
|
711
|
+
)
|
|
712
|
+
self.crop_to_content(
|
|
713
|
+
threshold=config.resolved_crop_threshold,
|
|
714
|
+
padding=config.crop_content_padding,
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
if config.resample:
|
|
718
|
+
self.resample(config.target_spacing)
|
|
719
|
+
|
|
720
|
+
if config.dimensionality == "2D":
|
|
721
|
+
if config.slice_selection_mode == "mask" and self.mask is None:
|
|
722
|
+
raise ValueError(
|
|
723
|
+
f"{self.series_id or 'image'}: 2D output with "
|
|
724
|
+
"slice_selection_mode='mask' requires a mask to pick the slice "
|
|
725
|
+
"from. Enable segmentation, supply a mask, or set "
|
|
726
|
+
"slice_selection_mode='index' with slice_index=<number>."
|
|
727
|
+
)
|
|
728
|
+
self.select_slice(
|
|
729
|
+
mode=config.slice_selection_mode,
|
|
730
|
+
index=config.slice_index,
|
|
731
|
+
label=config.slice_selection_label,
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
if config.mask:
|
|
735
|
+
if self.mask is None:
|
|
736
|
+
logger.warning(
|
|
737
|
+
"%s: mask=True but no mask is available; skipping the masking step.",
|
|
738
|
+
self.series_id or "image",
|
|
739
|
+
)
|
|
740
|
+
else:
|
|
741
|
+
self.apply_mask(
|
|
742
|
+
labels=config.mask_labels,
|
|
743
|
+
crop=config.crop_to_mask,
|
|
744
|
+
padding=config.crop_padding,
|
|
745
|
+
fill_value=config.clip_min if config.clip else None,
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
if config.standardize_size:
|
|
749
|
+
if config.target_shape is None:
|
|
750
|
+
logger.warning(
|
|
751
|
+
"%s: standardize_size=True but target_shape is None. A single "
|
|
752
|
+
"image has no cohort to take percentiles over; skipping. Use "
|
|
753
|
+
"Dataset.process() or set config.target_shape.",
|
|
754
|
+
self.series_id or "image",
|
|
755
|
+
)
|
|
756
|
+
else:
|
|
757
|
+
self.standardize_size(
|
|
758
|
+
*config.target_shape,
|
|
759
|
+
fill_value=config.clip_min if config.clip else None,
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
if config.normalize:
|
|
763
|
+
self.normalize(
|
|
764
|
+
method=config.normalization_method,
|
|
765
|
+
mean=config.dataset_mean,
|
|
766
|
+
std=config.dataset_std,
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
return self
|
|
770
|
+
|
|
771
|
+
# ------------------------------------------------------------------
|
|
772
|
+
# analysis
|
|
773
|
+
# ------------------------------------------------------------------
|
|
774
|
+
def check(
|
|
775
|
+
self,
|
|
776
|
+
criteria: Union[qc_module.QCCriteria, QCPreset, None] = None,
|
|
777
|
+
level: QCLevel = "all",
|
|
778
|
+
**thresholds: Any,
|
|
779
|
+
) -> qc_module.QCResult:
|
|
780
|
+
"""Run quality control on this image. See :mod:`.qc`.
|
|
781
|
+
|
|
782
|
+
`criteria` is a :class:`QCCriteria` or the name of a preset; single
|
|
783
|
+
thresholds can be given as keywords, as in ``check(min_slices=25)``.
|
|
784
|
+
|
|
785
|
+
`level` selects which checks run: ``"metadata"`` uses only the headers
|
|
786
|
+
and :attr:`metadata`, so no pixel data is read; ``"volume"`` uses only
|
|
787
|
+
the reconstructed volume; ``"all"`` (the default) runs both and merges
|
|
788
|
+
the results.
|
|
789
|
+
"""
|
|
790
|
+
criteria = qc_module.resolve_criteria(criteria, **thresholds)
|
|
791
|
+
|
|
792
|
+
sources: list = []
|
|
793
|
+
if level != "volume":
|
|
794
|
+
if self.metadata:
|
|
795
|
+
sources.append(self.metadata)
|
|
796
|
+
if self.source is not None and os.path.isdir(str(self.source)):
|
|
797
|
+
sources.append(str(self.source))
|
|
798
|
+
|
|
799
|
+
return qc_module.check(
|
|
800
|
+
None if level == "metadata" else self.image,
|
|
801
|
+
criteria,
|
|
802
|
+
series_id=self.series_id,
|
|
803
|
+
metadata=sources or None,
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
def radiomics(
|
|
807
|
+
self,
|
|
808
|
+
labels: Labels = (1, 2),
|
|
809
|
+
params: Optional[Union[str, dict]] = None,
|
|
810
|
+
**kwargs: Any,
|
|
811
|
+
) -> dict:
|
|
812
|
+
"""Extract PyRadiomics features from the masked region. See :mod:`.features`."""
|
|
813
|
+
from .features import extract_features
|
|
814
|
+
|
|
815
|
+
return extract_features(self, labels=labels, params=params, **kwargs)
|
|
816
|
+
|
|
817
|
+
def statistics(self) -> dict:
|
|
818
|
+
"""Summary statistics, useful for spotting inconsistent preprocessing."""
|
|
819
|
+
data = self.array
|
|
820
|
+
stats = {
|
|
821
|
+
"series_id": self.series_id,
|
|
822
|
+
"shape": self.shape,
|
|
823
|
+
"spacing": self.spacing,
|
|
824
|
+
"orientation": self.orientation,
|
|
825
|
+
"min": float(data.min()),
|
|
826
|
+
"max": float(data.max()),
|
|
827
|
+
"mean": float(data.mean()),
|
|
828
|
+
"std": float(data.std()),
|
|
829
|
+
}
|
|
830
|
+
if self.mask is not None:
|
|
831
|
+
mask_data = self.mask_array
|
|
832
|
+
stats["mask_labels"] = self.labels
|
|
833
|
+
stats["mask_voxels"] = int(np.count_nonzero(mask_data))
|
|
834
|
+
return stats
|
|
835
|
+
|
|
836
|
+
# ------------------------------------------------------------------
|
|
837
|
+
# output
|
|
838
|
+
# ------------------------------------------------------------------
|
|
839
|
+
def save(
|
|
840
|
+
self,
|
|
841
|
+
path: PathArg,
|
|
842
|
+
mask_path: Optional[PathArg] = None,
|
|
843
|
+
output_format: OutputFormat = "nifti",
|
|
844
|
+
compress: bool = True,
|
|
845
|
+
save_mask: bool = True,
|
|
846
|
+
) -> str:
|
|
847
|
+
"""Write the processed image (and mask) — the only write in the pipeline.
|
|
848
|
+
|
|
849
|
+
`path` may name a file or a directory; a directory gets
|
|
850
|
+
``<series_id>.nii.gz`` and ``<series_id>_mask.nii.gz``.
|
|
851
|
+
|
|
852
|
+
Returns the image path written.
|
|
853
|
+
"""
|
|
854
|
+
if os.path.isdir(path) or path.endswith(os.sep):
|
|
855
|
+
name = self.series_id or "image"
|
|
856
|
+
path = os.path.join(path, name)
|
|
857
|
+
|
|
858
|
+
image_path = save_image(
|
|
859
|
+
self.image, path, output_format=output_format, compress=compress
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
if save_mask and self.mask is not None:
|
|
863
|
+
if mask_path is None:
|
|
864
|
+
base = resolve_output_path(path, output_format=output_format, compress=compress)
|
|
865
|
+
mask_path = _add_suffix(base, "_mask")
|
|
866
|
+
save_image(self.mask, mask_path, output_format=output_format, compress=compress)
|
|
867
|
+
|
|
868
|
+
return image_path
|
|
869
|
+
|
|
870
|
+
def plot(
|
|
871
|
+
self,
|
|
872
|
+
z: Optional[int] = None,
|
|
873
|
+
overlay_mask: bool = True,
|
|
874
|
+
window: Optional[tuple] = None,
|
|
875
|
+
out_path: Optional[str] = None,
|
|
876
|
+
title: Optional[str] = None,
|
|
877
|
+
ax=None,
|
|
878
|
+
):
|
|
879
|
+
"""Show one slice, optionally with the mask overlaid.
|
|
880
|
+
|
|
881
|
+
With `z` omitted, the slice with the most mask is chosen (or the middle
|
|
882
|
+
slice when there is no mask). Inside Jupyter, pass ``z=None`` and call
|
|
883
|
+
:meth:`view` instead for an interactive slider.
|
|
884
|
+
"""
|
|
885
|
+
import matplotlib.pyplot as plt
|
|
886
|
+
from matplotlib.colors import ListedColormap
|
|
887
|
+
|
|
888
|
+
data = self.array
|
|
889
|
+
mask_data = self.mask_array if (overlay_mask and self.mask is not None) else None
|
|
890
|
+
|
|
891
|
+
if data.ndim == 2:
|
|
892
|
+
plane, mask_plane, index = data, mask_data, 0
|
|
893
|
+
else:
|
|
894
|
+
if z is None:
|
|
895
|
+
if mask_data is not None and np.any(mask_data):
|
|
896
|
+
z = int(np.argmax((mask_data > 0).sum(axis=(0, 1))))
|
|
897
|
+
else:
|
|
898
|
+
z = data.shape[2] // 2
|
|
899
|
+
index = int(z)
|
|
900
|
+
plane = data[:, :, index]
|
|
901
|
+
mask_plane = None if mask_data is None else mask_data[:, :, index]
|
|
902
|
+
|
|
903
|
+
vmin, vmax = window if window else (None, None)
|
|
904
|
+
if ax is None:
|
|
905
|
+
_, ax = plt.subplots(figsize=(6, 6))
|
|
906
|
+
ax.imshow(np.rot90(plane), cmap="gray", vmin=vmin, vmax=vmax)
|
|
907
|
+
if mask_plane is not None:
|
|
908
|
+
overlay = ListedColormap([(0, 0, 0, 0), (1, 0, 0, 0.7), (1, 0.5, 0, 0.9)])
|
|
909
|
+
ax.imshow(
|
|
910
|
+
np.rot90(np.clip(mask_plane, 0, 2)), cmap=overlay, alpha=0.35,
|
|
911
|
+
vmin=0, vmax=2,
|
|
912
|
+
)
|
|
913
|
+
ax.axis("off")
|
|
914
|
+
ax.set_title(title if title is not None else f"{self.series_id or 'image'} [z={index}]")
|
|
915
|
+
|
|
916
|
+
if out_path:
|
|
917
|
+
os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
|
|
918
|
+
ax.figure.savefig(out_path, bbox_inches="tight", dpi=200)
|
|
919
|
+
return ax
|
|
920
|
+
|
|
921
|
+
def view(self, overlay_mask: bool = True, window: Optional[tuple] = None):
|
|
922
|
+
"""Interactive slice slider for Jupyter notebooks."""
|
|
923
|
+
from ipywidgets import interact
|
|
924
|
+
|
|
925
|
+
if self.ndim < 3:
|
|
926
|
+
return self.plot(overlay_mask=overlay_mask, window=window)
|
|
927
|
+
|
|
928
|
+
def show(z: int):
|
|
929
|
+
import matplotlib.pyplot as plt
|
|
930
|
+
|
|
931
|
+
self.plot(z=z, overlay_mask=overlay_mask, window=window)
|
|
932
|
+
plt.show()
|
|
933
|
+
|
|
934
|
+
return interact(show, z=(0, self.shape[2] - 1))
|
|
935
|
+
|
|
936
|
+
# ------------------------------------------------------------------
|
|
937
|
+
# internals
|
|
938
|
+
# ------------------------------------------------------------------
|
|
939
|
+
def _record(self, step: str, **details: Any) -> "RadiologyImage":
|
|
940
|
+
self.history.append({"step": step, **details})
|
|
941
|
+
return self
|
|
942
|
+
|
|
943
|
+
@property
|
|
944
|
+
def applied_steps(self) -> list:
|
|
945
|
+
"""Names of the steps applied so far, in order."""
|
|
946
|
+
return [entry["step"] for entry in self.history]
|
|
947
|
+
|
|
948
|
+
def filter(self, *args: Any, **kwargs: Any):
|
|
949
|
+
"""Not an image operation — defined only to say so.
|
|
950
|
+
|
|
951
|
+
A method rather than ``__getattr__``, so that mypy and editors keep
|
|
952
|
+
flagging every *other* misspelled attribute.
|
|
953
|
+
"""
|
|
954
|
+
raise AttributeError(
|
|
955
|
+
"filter() drops series from a cohort. For one image use check(), "
|
|
956
|
+
"which returns a QCResult, or build a Dataset to filter over."
|
|
957
|
+
)
|
|
958
|
+
|
|
959
|
+
def __repr__(self) -> str:
|
|
960
|
+
name = self.series_id or "unnamed"
|
|
961
|
+
if not self._loaded:
|
|
962
|
+
return f"<RadiologyImage {name} (not loaded)>"
|
|
963
|
+
parts = [
|
|
964
|
+
f"shape={self.shape}",
|
|
965
|
+
f"spacing={tuple(round(value, 3) for value in self.spacing)}",
|
|
966
|
+
f"orientation={self.orientation}",
|
|
967
|
+
]
|
|
968
|
+
if self.mask is not None:
|
|
969
|
+
parts.append(f"labels={self.labels}")
|
|
970
|
+
if self.history:
|
|
971
|
+
parts.append(f"steps={'>'.join(self.applied_steps)}")
|
|
972
|
+
return f"<RadiologyImage {name} {' '.join(parts)}>"
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
# ----------------------------------------------------------------------
|
|
976
|
+
# array / geometry helpers
|
|
977
|
+
# ----------------------------------------------------------------------
|
|
978
|
+
def _label_text(label: Union[int, Sequence[int]]) -> str:
|
|
979
|
+
if isinstance(label, (list, tuple, set, np.ndarray)):
|
|
980
|
+
return ",".join(str(value) for value in label)
|
|
981
|
+
return str(label)
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def _working_dtype(dtype: np.dtype) -> np.dtype:
|
|
985
|
+
"""Keep floats as they are; promote integers to float32 for arithmetic."""
|
|
986
|
+
return dtype if np.issubdtype(dtype, np.floating) else np.dtype(np.float32)
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _with_data(reference: nib.Nifti1Image, data: np.ndarray) -> nib.Nifti1Image:
|
|
990
|
+
"""New image with `data`, keeping the geometry of `reference`."""
|
|
991
|
+
header = reference.header.copy()
|
|
992
|
+
header.set_data_dtype(data.dtype)
|
|
993
|
+
return nib.Nifti1Image(data, reference.affine, header)
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def _copy_nifti(image: Optional[nib.Nifti1Image]) -> Optional[nib.Nifti1Image]:
|
|
997
|
+
if image is None:
|
|
998
|
+
return None
|
|
999
|
+
return nib.Nifti1Image(
|
|
1000
|
+
np.array(np.asanyarray(image.dataobj), copy=True),
|
|
1001
|
+
image.affine.copy(),
|
|
1002
|
+
image.header.copy(),
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def _as_label_map(mask: nib.Nifti1Image) -> nib.Nifti1Image:
|
|
1007
|
+
data = np.rint(np.asanyarray(mask.dataobj)).astype(np.uint8)
|
|
1008
|
+
return _with_data(mask, data)
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def _reorient_to(image: nib.Nifti1Image, target: str) -> nib.Nifti1Image:
|
|
1012
|
+
"""Reorient to arbitrary axis codes such as ``"LPS"``."""
|
|
1013
|
+
try:
|
|
1014
|
+
target_ornt = nib.orientations.axcodes2ornt(tuple(target))
|
|
1015
|
+
except (KeyError, ValueError) as error:
|
|
1016
|
+
raise ValueError(
|
|
1017
|
+
f"Invalid orientation {target!r}; expected three axis codes such as "
|
|
1018
|
+
"'RAS', 'LPS' or 'LAS'."
|
|
1019
|
+
) from error
|
|
1020
|
+
|
|
1021
|
+
data = np.asanyarray(image.dataobj)
|
|
1022
|
+
current_ornt = nib.orientations.axcodes2ornt(nib.orientations.aff2axcodes(image.affine))
|
|
1023
|
+
transform = nib.orientations.ornt_transform(current_ornt, target_ornt)
|
|
1024
|
+
reoriented = nib.orientations.apply_orientation(data, transform)
|
|
1025
|
+
affine = image.affine @ nib.orientations.inv_ornt_aff(transform, data.shape)
|
|
1026
|
+
return nib.Nifti1Image(reoriented, affine, image.header)
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def _resample_nifti(
|
|
1030
|
+
image: nib.Nifti1Image, target_spacing: Sequence[float], interpolator
|
|
1031
|
+
) -> nib.Nifti1Image:
|
|
1032
|
+
import SimpleITK as sitk
|
|
1033
|
+
|
|
1034
|
+
source = nifti_to_sitk(image)
|
|
1035
|
+
original_spacing = source.GetSpacing()
|
|
1036
|
+
original_size = source.GetSize()
|
|
1037
|
+
|
|
1038
|
+
new_size = [
|
|
1039
|
+
max(1, int(round(original_size[axis] * (original_spacing[axis] / target_spacing[axis]))))
|
|
1040
|
+
for axis in range(len(original_size))
|
|
1041
|
+
]
|
|
1042
|
+
|
|
1043
|
+
resampler = sitk.ResampleImageFilter()
|
|
1044
|
+
resampler.SetOutputSpacing([float(value) for value in target_spacing])
|
|
1045
|
+
resampler.SetSize(new_size)
|
|
1046
|
+
resampler.SetOutputDirection(source.GetDirection())
|
|
1047
|
+
resampler.SetOutputOrigin(source.GetOrigin())
|
|
1048
|
+
resampler.SetTransform(sitk.Transform())
|
|
1049
|
+
resampler.SetInterpolator(interpolator)
|
|
1050
|
+
resampler.SetDefaultPixelValue(float(sitk.GetArrayViewFromImage(source).min()))
|
|
1051
|
+
|
|
1052
|
+
result = sitk_to_nifti(resampler.Execute(source))
|
|
1053
|
+
return nib.Nifti1Image(np.asanyarray(result.dataobj), result.affine, image.header)
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def _bounding_box(
|
|
1057
|
+
data: np.ndarray, threshold: Optional[Number] = None, padding: int = 0
|
|
1058
|
+
) -> Optional[tuple]:
|
|
1059
|
+
"""Bounding box of voxels strictly above `threshold`, as (min, max) per axis."""
|
|
1060
|
+
if threshold is None:
|
|
1061
|
+
threshold = float(data.min())
|
|
1062
|
+
occupied = np.argwhere(data > threshold)
|
|
1063
|
+
if occupied.size == 0:
|
|
1064
|
+
return None
|
|
1065
|
+
|
|
1066
|
+
mins = occupied.min(axis=0)
|
|
1067
|
+
maxs = occupied.max(axis=0) + 1
|
|
1068
|
+
if padding:
|
|
1069
|
+
mins = np.maximum(mins - padding, 0)
|
|
1070
|
+
maxs = np.minimum(maxs + padding, np.array(data.shape))
|
|
1071
|
+
return tuple((int(low), int(high)) for low, high in zip(mins, maxs))
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def _crop(image: nib.Nifti1Image, bbox: Sequence[tuple]) -> nib.Nifti1Image:
|
|
1075
|
+
data = np.asanyarray(image.dataobj)
|
|
1076
|
+
selector = tuple(slice(low, high) for low, high in bbox)
|
|
1077
|
+
cropped = data[selector]
|
|
1078
|
+
offset = [low for low, _ in bbox]
|
|
1079
|
+
return nib.Nifti1Image(cropped, _shift_affine(image.affine, offset), image.header)
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def _shift_affine(affine: np.ndarray, offset: Sequence[int]) -> np.ndarray:
|
|
1083
|
+
"""Move the affine origin by a voxel `offset`, so world coordinates hold."""
|
|
1084
|
+
shifted = affine.copy()
|
|
1085
|
+
ndim = min(3, len(offset))
|
|
1086
|
+
voxel_offset = np.zeros(3)
|
|
1087
|
+
voxel_offset[:ndim] = offset[:ndim]
|
|
1088
|
+
shifted[:3, 3] = affine[:3, :3] @ voxel_offset + affine[:3, 3]
|
|
1089
|
+
return shifted
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
def _crop_or_pad(
|
|
1093
|
+
image: nib.Nifti1Image, dims: Sequence[Optional[int]], fill_value: Number
|
|
1094
|
+
) -> nib.Nifti1Image:
|
|
1095
|
+
"""Center-crop and/or center-pad each axis to `dims`."""
|
|
1096
|
+
data = np.asanyarray(image.dataobj)
|
|
1097
|
+
current = data.shape
|
|
1098
|
+
target = tuple(
|
|
1099
|
+
int(dims[axis]) if axis < len(dims) and dims[axis] is not None else current[axis]
|
|
1100
|
+
for axis in range(data.ndim)
|
|
1101
|
+
)
|
|
1102
|
+
|
|
1103
|
+
out = np.full(target, fill_value, dtype=data.dtype)
|
|
1104
|
+
source_slices, dest_slices, offsets = [], [], []
|
|
1105
|
+
for axis in range(data.ndim):
|
|
1106
|
+
source_size, dest_size = current[axis], target[axis]
|
|
1107
|
+
if source_size >= dest_size:
|
|
1108
|
+
start = (source_size - dest_size) // 2
|
|
1109
|
+
source_slices.append(slice(start, start + dest_size))
|
|
1110
|
+
dest_slices.append(slice(0, dest_size))
|
|
1111
|
+
offsets.append(start)
|
|
1112
|
+
else:
|
|
1113
|
+
start = (dest_size - source_size) // 2
|
|
1114
|
+
source_slices.append(slice(0, source_size))
|
|
1115
|
+
dest_slices.append(slice(start, start + source_size))
|
|
1116
|
+
offsets.append(-start)
|
|
1117
|
+
|
|
1118
|
+
out[tuple(dest_slices)] = data[tuple(source_slices)]
|
|
1119
|
+
return nib.Nifti1Image(out, _shift_affine(image.affine, offsets), image.header)
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def _add_suffix(path: str, suffix: str) -> str:
|
|
1123
|
+
for extension in (".nii.gz", ".nii", ".npy"):
|
|
1124
|
+
if path.lower().endswith(extension):
|
|
1125
|
+
return path[: -len(extension)] + suffix + extension
|
|
1126
|
+
return path + suffix
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
def _organs_from_metadata(metadata: dict) -> Optional[list]:
|
|
1130
|
+
"""Look up curated organ names when the image knows which collection it came from."""
|
|
1131
|
+
from .constants import tcia_dataset_to_info
|
|
1132
|
+
|
|
1133
|
+
for key in ("dataset", "subproject", "collection", "Collection"):
|
|
1134
|
+
value = metadata.get(key)
|
|
1135
|
+
if not value:
|
|
1136
|
+
continue
|
|
1137
|
+
normalized = str(value).lower().replace("_", "-")
|
|
1138
|
+
info = tcia_dataset_to_info.get(normalized)
|
|
1139
|
+
if info and info.get("totalsegmentator_organs"):
|
|
1140
|
+
return list(info["totalsegmentator_organs"])
|
|
1141
|
+
return None
|