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 ADDED
@@ -0,0 +1,144 @@
1
+ """ctkit: reproducible CT image processing for AI and radiomics.
2
+
3
+ The package exists so that "how was this image processed?" has an exact,
4
+ runnable answer. A protocol is a :class:`ProcessingConfig`; applying it to one
5
+ scan is :class:`RadiologyImage`, and to a cohort is :class:`Dataset`.
6
+
7
+ Quick start — construct a cohort or a scan, then call the steps on it::
8
+
9
+ import ctkit
10
+
11
+ ctkit.download("tcga-kirc", "data/raw", limit=20)
12
+
13
+ data = ctkit.Dataset("data/raw")
14
+ data.filter(min_slices=25).process("tcga-kirc", out_dir="data/processed")
15
+
16
+ The same steps chain on one scan, modifying it in place::
17
+
18
+ scan = ctkit.RadiologyImage("case/imaging.nii.gz", mask="case/segmentation.nii.gz")
19
+ scan.orient().clip(-200, 300).resample((0.8, 0.8, 3.0)).save("processed/case.nii.gz")
20
+
21
+ Each step is also a function taking whatever you have — a path, an array, a
22
+ scan, or a cohort::
23
+
24
+ ctkit.clip("case/imaging.nii.gz", -200, 300)
25
+
26
+ and a field of a :class:`ProcessingConfig`, which is what makes a protocol
27
+ something you can print, save to YAML, and publish alongside a paper.
28
+
29
+ Nothing is written to disk until ``save()``; intermediate volumes only ever
30
+ exist in memory.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from importlib import import_module
36
+ from typing import TYPE_CHECKING
37
+
38
+ __version__ = "0.1.0"
39
+
40
+ from .api import (
41
+ apply_mask,
42
+ check,
43
+ clip,
44
+ crop_to_content,
45
+ filter,
46
+ normalize,
47
+ orient,
48
+ process,
49
+ radiomics,
50
+ resample,
51
+ save,
52
+ segment,
53
+ select_slice,
54
+ standardize_size,
55
+ )
56
+ from .config import ProcessingConfig
57
+ from .constants import tcia_dataset_to_info
58
+ from .dataset import Dataset
59
+ from .datasets import EXTRA_CT_COLLECTIONS, get_dataset_info, list_datasets
60
+ from .image import RadiologyImage
61
+ from .io import dicom_to_nifti, load_image, save_image
62
+ from .metadata import annotate, categorize_phase, categorize_region, summarize
63
+ from .qc import QCCriteria, QCResult, check_series_metadata, check_volume
64
+
65
+ if TYPE_CHECKING: # pragma: no cover
66
+ from . import features, segmentation
67
+ from .tcia import download, list_collections
68
+
69
+ __all__ = [
70
+ "__version__",
71
+ # pipeline steps, in the order they run
72
+ "filter",
73
+ "orient",
74
+ "segment",
75
+ "clip",
76
+ "resample",
77
+ "select_slice",
78
+ "apply_mask",
79
+ "crop_to_content",
80
+ "standardize_size",
81
+ "normalize",
82
+ # whole protocols and output
83
+ "process",
84
+ "save",
85
+ "radiomics",
86
+ # core
87
+ "RadiologyImage",
88
+ "Dataset",
89
+ "ProcessingConfig",
90
+ # quality control
91
+ "QCCriteria",
92
+ "QCResult",
93
+ "check",
94
+ "check_volume",
95
+ "check_series_metadata",
96
+ # data access
97
+ "download",
98
+ "download_supplementary",
99
+ "list_collections",
100
+ "list_datasets",
101
+ "get_dataset_info",
102
+ "EXTRA_CT_COLLECTIONS",
103
+ "tcia_dataset_to_info",
104
+ # io
105
+ "load_image",
106
+ "save_image",
107
+ "dicom_to_nifti",
108
+ # metadata
109
+ "categorize_region",
110
+ "categorize_phase",
111
+ "annotate",
112
+ "summarize",
113
+ # submodules
114
+ "features",
115
+ "segmentation",
116
+ "tcia",
117
+ "metadata",
118
+ ]
119
+
120
+ #: Attributes served on first use, so that importing the package does not pull
121
+ #: in `requests`, `pyradiomics` or `matplotlib`.
122
+ _LAZY = {
123
+ "download": ("tcia", "download"),
124
+ "download_supplementary": ("tcia", "download_supplementary"),
125
+ "list_collections": ("tcia", "list_collections"),
126
+ "download_with_nbia_retriever": ("tcia", "download_with_nbia_retriever"),
127
+ "get_series": ("tcia", "get_series"),
128
+ "extract_features": ("features", "extract_features"),
129
+ "segment_organs": ("segmentation", "segment_organs"),
130
+ }
131
+ _LAZY_MODULES = {"features", "segmentation", "tcia", "io", "qc", "datasets", "metadata"}
132
+
133
+
134
+ def __getattr__(name: str):
135
+ if name in _LAZY:
136
+ module_name, attribute = _LAZY[name]
137
+ return getattr(import_module(f".{module_name}", __name__), attribute)
138
+ if name in _LAZY_MODULES:
139
+ return import_module(f".{name}", __name__)
140
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
141
+
142
+
143
+ def __dir__() -> list:
144
+ return sorted(set(__all__) | set(globals()))
ctkit/api.py ADDED
@@ -0,0 +1,358 @@
1
+ """The functional API: ``import ctkit`` and call the steps directly.
2
+
3
+ Every pipeline step is a function here, so a protocol can be written without
4
+ constructing anything::
5
+
6
+ import ctkit
7
+
8
+ scan = ctkit.orient("case/imaging.nii.gz")
9
+ ctkit.clip(scan, -200, 300)
10
+ ctkit.resample(scan, (0.8, 0.8, 3.0))
11
+ ctkit.save(scan, "processed/case.nii.gz")
12
+
13
+ Each function takes whatever you have — a path, a NumPy array, a
14
+ :class:`~ctkit.image.RadiologyImage`, a :class:`~ctkit.dataset.Dataset`, or a
15
+ directory of scans — and returns the same kind of thing, so the same call works
16
+ on one scan or on a cohort::
17
+
18
+ ctkit.orient(image) # -> RadiologyImage
19
+ ctkit.orient("data/raw") # -> Dataset, every series reoriented
20
+
21
+ These are the methods, not a reimplementation of them: ``ctkit.clip(image)``
22
+ does exactly what ``image.clip()`` does, including modifying the image in
23
+ place and returning it. Construct a :class:`~ctkit.dataset.Dataset` or a
24
+ :class:`~ctkit.image.RadiologyImage` instead when you would rather chain::
25
+
26
+ ctkit.Dataset("data/raw").filter(min_slices=25).orient().clip(-200, 300)
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import os
32
+ from collections.abc import Mapping
33
+ from typing import Any, Optional, Sequence, Union
34
+
35
+ from .dataset import Dataset
36
+ from .image import RadiologyImage
37
+ from .io import ImageLike, is_dicom_directory
38
+ from .qc import QCCriteria, QCResult, resolve_criteria
39
+ from .qc import check as _check_metadata
40
+ from .validation import (
41
+ Integer,
42
+ Interpolator,
43
+ Labels,
44
+ NormalizationMethod,
45
+ Number,
46
+ OutputFormat,
47
+ PathArg,
48
+ QCLevel,
49
+ QCPreset,
50
+ SliceMode,
51
+ Spacing,
52
+ validated,
53
+ )
54
+
55
+ __all__ = [
56
+ "filter",
57
+ "check",
58
+ "orient",
59
+ "segment",
60
+ "clip",
61
+ "resample",
62
+ "select_slice",
63
+ "apply_mask",
64
+ "crop_to_content",
65
+ "standardize_size",
66
+ "normalize",
67
+ "process",
68
+ "save",
69
+ "radiomics",
70
+ ]
71
+
72
+ #: Anything a function here accepts: one image, a cohort, or a path to either.
73
+ Target = Union[RadiologyImage, Dataset, ImageLike]
74
+
75
+
76
+ # ----------------------------------------------------------------------
77
+ # input handling
78
+ # ----------------------------------------------------------------------
79
+ def _resolve(target: Target) -> Union[RadiologyImage, Dataset]:
80
+ """One image or a cohort, whichever the input describes.
81
+
82
+ A path to a file is one image; a directory, a metadata table or a list is
83
+ a cohort. A directory of DICOM slices is one image, not a cohort of one
84
+ per file.
85
+ """
86
+ if isinstance(target, (RadiologyImage, Dataset)):
87
+ return target
88
+ if isinstance(target, (list, tuple)):
89
+ return Dataset(target)
90
+ if isinstance(target, (str, os.PathLike)):
91
+ path = str(target)
92
+ if os.path.isdir(path) and not is_dicom_directory(path):
93
+ return Dataset(path)
94
+ if path.lower().endswith((".csv", ".tsv")) or any(c in path for c in "*?["):
95
+ return Dataset(path)
96
+ return RadiologyImage(path)
97
+ return RadiologyImage(target)
98
+
99
+
100
+ def _is_metadata(target: Any) -> bool:
101
+ """True for a DICOM header or a metadata row, which carry no pixel data."""
102
+ if isinstance(target, (RadiologyImage, Dataset, str, os.PathLike, list, tuple)):
103
+ return False
104
+ return isinstance(target, Mapping) or callable(getattr(target, "keys", None))
105
+
106
+
107
+ def _step(name: str, subject: Target, /, *args: Any, **kwargs: Any):
108
+ """Run one step, on an image or over a cohort.
109
+
110
+ Both classes carry the step under the same name, so this dispatches to the
111
+ method rather than reimplementing it — which also means the arguments are
112
+ validated here, before a cohort is even discovered.
113
+
114
+ Positional-only, so a step's own arguments (``target=``, ``name=``) cannot
115
+ collide with these.
116
+ """
117
+ return getattr(_resolve(subject), name)(*args, **kwargs)
118
+
119
+
120
+ # ----------------------------------------------------------------------
121
+ # quality control
122
+ # ----------------------------------------------------------------------
123
+ @validated
124
+ def filter(
125
+ images: Target,
126
+ criteria: Union[QCCriteria, QCPreset, None] = None,
127
+ level: QCLevel = "all",
128
+ progress: bool = True,
129
+ **thresholds: Any,
130
+ ) -> Dataset:
131
+ """Drop series that fail quality control, returning the ones that passed.
132
+
133
+ `criteria` is a :class:`QCCriteria`, or the name of a preset
134
+ (``"default"``, ``"radiomics"``, ``"permissive"``). Individual thresholds
135
+ can also be given as keywords: ``ctkit.filter(data, min_slices=25)``.
136
+
137
+ `level` is ``"metadata"`` (headers only, reads no pixels), ``"volume"``, or
138
+ ``"all"``. The result carries ``.qc_report``, the pass/fail table for every
139
+ series, and ``.rejected``, the images that failed.
140
+ """
141
+ return Dataset(images).filter(
142
+ criteria, level=level, progress=progress, **thresholds
143
+ )
144
+
145
+
146
+ @validated
147
+ def check(
148
+ target: Any,
149
+ criteria: Union[QCCriteria, QCPreset, None] = None,
150
+ level: QCLevel = "all",
151
+ progress: bool = True,
152
+ **thresholds: Any,
153
+ ) -> Union[QCResult, Any]:
154
+ """Run quality control without dropping anything.
155
+
156
+ On one image this is a :class:`QCResult` (``.passed``, ``.reason``,
157
+ ``.stats``); on a cohort it is the full pass/fail table as a DataFrame. A
158
+ DICOM header or metadata row can be passed directly, which checks it
159
+ without reading any pixels.
160
+ """
161
+ if _is_metadata(target):
162
+ return _check_metadata(None, resolve_criteria(criteria, **thresholds), metadata=target)
163
+
164
+ resolved = _resolve(target)
165
+ if isinstance(resolved, Dataset):
166
+ return resolved.check(criteria, level=level, progress=progress, **thresholds)
167
+ return resolved.check(criteria, level=level, **thresholds)
168
+
169
+
170
+ # ----------------------------------------------------------------------
171
+ # pipeline steps
172
+ # ----------------------------------------------------------------------
173
+ @validated
174
+ def orient(image: Target, target: str = "RAS"):
175
+ """Reorient to a canonical anatomical axis order. See :meth:`RadiologyImage.orient`."""
176
+ return _step("orient", image, target=target)
177
+
178
+
179
+ @validated
180
+ def segment(
181
+ image: Target,
182
+ organs: Optional[Sequence[str]] = None,
183
+ task: str = "total",
184
+ tumor_mask: Optional[ImageLike] = None,
185
+ restrict_organs_to_tumor: bool = True,
186
+ fast: bool = False,
187
+ remove_small_blobs: bool = True,
188
+ fill_holes: bool = True,
189
+ morphological_closing: bool = True,
190
+ device: Optional[str] = None,
191
+ output_dir: Optional[PathArg] = None,
192
+ replace: bool = False,
193
+ ):
194
+ """Segment organs with TotalSegmentator. See :meth:`RadiologyImage.segment`.
195
+
196
+ On a cohort, `output_dir` holds one subdirectory per series.
197
+ """
198
+ # Dataset.segment rather than _step: on a cohort it is the one that gives
199
+ # each series its own output subdirectory.
200
+ resolved = _resolve(image)
201
+ return resolved.segment(
202
+ organs=organs,
203
+ task=task,
204
+ tumor_mask=tumor_mask,
205
+ restrict_organs_to_tumor=restrict_organs_to_tumor,
206
+ fast=fast,
207
+ remove_small_blobs=remove_small_blobs,
208
+ fill_holes=fill_holes,
209
+ morphological_closing=morphological_closing,
210
+ device=device,
211
+ output_dir=output_dir,
212
+ replace=replace,
213
+ )
214
+
215
+
216
+ @validated
217
+ def clip(
218
+ image: Target,
219
+ min_value: Optional[Number] = -200,
220
+ max_value: Optional[Number] = 300,
221
+ ):
222
+ """Clamp intensities to a window in HU. See :meth:`RadiologyImage.clip`."""
223
+ return _step("clip", image, min_value=min_value, max_value=max_value)
224
+
225
+
226
+ @validated
227
+ def resample(
228
+ image: Target,
229
+ spacing: Spacing = (0.8, 0.8, 3.0),
230
+ interpolator: Interpolator = "linear",
231
+ ):
232
+ """Resample onto a fixed voxel size in mm. See :meth:`RadiologyImage.resample`."""
233
+ return _step("resample", image, spacing=spacing, interpolator=interpolator)
234
+
235
+
236
+ @validated
237
+ def select_slice(
238
+ image: Target,
239
+ mode: SliceMode = "mask",
240
+ index: Optional[Integer] = None,
241
+ label: Optional[Labels] = None,
242
+ keepdims: bool = False,
243
+ ):
244
+ """Reduce a volume to one axial slice. See :meth:`RadiologyImage.select_slice`."""
245
+ return _step(
246
+ "select_slice", image, mode=mode, index=index, label=label, keepdims=keepdims
247
+ )
248
+
249
+
250
+ @validated
251
+ def apply_mask(
252
+ image: Target,
253
+ labels: Optional[Labels] = None,
254
+ crop: bool = True,
255
+ padding: Integer = 5,
256
+ fill_value: Optional[Number] = None,
257
+ ):
258
+ """Blank outside the ROI and crop to it. See :meth:`RadiologyImage.apply_mask`."""
259
+ return _step(
260
+ "apply_mask", image, labels=labels, crop=crop, padding=padding, fill_value=fill_value
261
+ )
262
+
263
+
264
+ @validated
265
+ def crop_to_content(image: Target, threshold: Optional[Number] = None, padding: Integer = 5):
266
+ """Crop to the voxels above `threshold`. See :meth:`RadiologyImage.crop_to_content`."""
267
+ return _step("crop_to_content", image, threshold=threshold, padding=padding)
268
+
269
+
270
+ @validated
271
+ def standardize_size(
272
+ image: Target,
273
+ x: Optional[Integer] = None,
274
+ y: Optional[Integer] = None,
275
+ z: Optional[Integer] = None,
276
+ fill_value: Optional[Number] = None,
277
+ mask_fill_value: Number = 0,
278
+ ):
279
+ """Center-crop or pad to a fixed shape. See :meth:`RadiologyImage.standardize_size`."""
280
+ return _step(
281
+ "standardize_size",
282
+ image,
283
+ x=x,
284
+ y=y,
285
+ z=z,
286
+ fill_value=fill_value,
287
+ mask_fill_value=mask_fill_value,
288
+ )
289
+
290
+
291
+ @validated
292
+ def normalize(
293
+ image: Target,
294
+ method: NormalizationMethod = "volume",
295
+ mean: Optional[Number] = None,
296
+ std: Optional[Number] = None,
297
+ within_mask: bool = False,
298
+ ):
299
+ """Z-score the intensities. See :meth:`RadiologyImage.normalize`.
300
+
301
+ Over a cohort, ``method="dataset"`` needs pooled statistics, which
302
+ :func:`process` resolves for you; here `mean` and `std` have to be given.
303
+ """
304
+ return _step(
305
+ "normalize", image, method=method, mean=mean, std=std, within_mask=within_mask
306
+ )
307
+
308
+
309
+ # ----------------------------------------------------------------------
310
+ # whole protocols, output, features
311
+ # ----------------------------------------------------------------------
312
+ @validated
313
+ def process(
314
+ target: Target,
315
+ config: Optional[Any] = None,
316
+ **kwargs: Any,
317
+ ):
318
+ """Run a whole protocol, in the order the steps are listed.
319
+
320
+ `config` is a :class:`ProcessingConfig`, the name of a collection whose
321
+ curated protocol to use (``ctkit.process(data, "tcga-kirc")``), or a path
322
+ to a saved YAML protocol. Any field can be overridden with a keyword.
323
+
324
+ Over a cohort this is :meth:`Dataset.process`, so it also takes `out_dir`,
325
+ `workers`, `skip_existing` and the rest, and it resolves the steps that
326
+ need cohort-level statistics.
327
+ """
328
+ return _resolve(target).process(config, **kwargs)
329
+
330
+
331
+ @validated
332
+ def save(
333
+ target: Target,
334
+ path: PathArg,
335
+ output_format: OutputFormat = "nifti",
336
+ compress: bool = True,
337
+ **kwargs: Any,
338
+ ):
339
+ """Write to `path` — a file for one image, a directory for a cohort.
340
+
341
+ NIfTI (``.nii.gz``, or ``.nii`` with ``compress=False``) or a NumPy
342
+ ``.npy`` array. Remaining keywords go to the method: `mask_path` and
343
+ `save_mask` for one image, `layout` and `progress` for a cohort.
344
+ """
345
+ return _resolve(target).save(
346
+ path, output_format=output_format, compress=compress, **kwargs
347
+ )
348
+
349
+
350
+ @validated
351
+ def radiomics(
352
+ target: Target,
353
+ labels: Labels = (1, 2),
354
+ params: Optional[Union[str, dict]] = None,
355
+ **kwargs: Any,
356
+ ):
357
+ """Extract PyRadiomics features: a dict for one image, a DataFrame for a cohort."""
358
+ return _resolve(target).radiomics(labels=labels, params=params, **kwargs)