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