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/io.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""Reading and writing images.
|
|
2
|
+
|
|
3
|
+
Everything in this package is held in memory as a :class:`nibabel.Nifti1Image`.
|
|
4
|
+
This module is the only place that touches the filesystem for image data, and
|
|
5
|
+
the only place that converts between NIfTI, SimpleITK and NumPy.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import tempfile
|
|
15
|
+
import zipfile
|
|
16
|
+
from typing import Any, Optional, Sequence, Union
|
|
17
|
+
|
|
18
|
+
import nibabel as nib
|
|
19
|
+
import numpy as np
|
|
20
|
+
import SimpleITK as sitk
|
|
21
|
+
|
|
22
|
+
from .validation import OUTPUT_FORMATS
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
NIFTI_SUFFIXES = (".nii", ".nii.gz")
|
|
27
|
+
ImageLike = Union[str, "os.PathLike[str]", nib.Nifti1Image, sitk.Image, np.ndarray]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ----------------------------------------------------------------------
|
|
31
|
+
# loading
|
|
32
|
+
# ----------------------------------------------------------------------
|
|
33
|
+
def load_image(
|
|
34
|
+
source: ImageLike,
|
|
35
|
+
affine: Optional[np.ndarray] = None,
|
|
36
|
+
spacing: Optional[Sequence[float]] = None,
|
|
37
|
+
) -> nib.Nifti1Image:
|
|
38
|
+
"""Load anything image-shaped into a :class:`nibabel.Nifti1Image`.
|
|
39
|
+
|
|
40
|
+
Accepts a path to a NIfTI file, a ``.npy`` array, a directory of DICOM
|
|
41
|
+
slices, a zip of DICOM slices, a single DICOM file, or an already-loaded
|
|
42
|
+
``Nifti1Image`` / ``SimpleITK.Image`` / :class:`numpy.ndarray`.
|
|
43
|
+
|
|
44
|
+
``affine`` or ``spacing`` are only used when the source carries no spatial
|
|
45
|
+
metadata of its own (a bare array).
|
|
46
|
+
"""
|
|
47
|
+
if isinstance(source, nib.Nifti1Image):
|
|
48
|
+
return source
|
|
49
|
+
|
|
50
|
+
if isinstance(source, sitk.Image):
|
|
51
|
+
return sitk_to_nifti(source)
|
|
52
|
+
|
|
53
|
+
if isinstance(source, np.ndarray):
|
|
54
|
+
return array_to_nifti(source, affine=affine, spacing=spacing)
|
|
55
|
+
|
|
56
|
+
if isinstance(source, (str, os.PathLike)):
|
|
57
|
+
return _load_path(str(source), affine=affine, spacing=spacing)
|
|
58
|
+
|
|
59
|
+
raise TypeError(
|
|
60
|
+
f"Cannot load an image from {type(source).__name__}. Expected a path, "
|
|
61
|
+
"a nibabel.Nifti1Image, a SimpleITK.Image, or a numpy.ndarray."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _load_path(
|
|
66
|
+
path: str,
|
|
67
|
+
affine: Optional[np.ndarray] = None,
|
|
68
|
+
spacing: Optional[Sequence[float]] = None,
|
|
69
|
+
) -> nib.Nifti1Image:
|
|
70
|
+
if not os.path.exists(path):
|
|
71
|
+
raise FileNotFoundError(f"No such image: {path}")
|
|
72
|
+
|
|
73
|
+
if os.path.isdir(path):
|
|
74
|
+
return dicom_to_nifti(path)
|
|
75
|
+
|
|
76
|
+
lower = path.lower()
|
|
77
|
+
if lower.endswith(NIFTI_SUFFIXES):
|
|
78
|
+
image = nib.load(path)
|
|
79
|
+
if not isinstance(image, nib.Nifti1Image): # .mgz and friends
|
|
80
|
+
image = nib.Nifti1Image(
|
|
81
|
+
np.asanyarray(image.dataobj), image.affine, None
|
|
82
|
+
)
|
|
83
|
+
return image
|
|
84
|
+
if lower.endswith(".npy"):
|
|
85
|
+
return array_to_nifti(np.load(path), affine=affine, spacing=spacing)
|
|
86
|
+
if lower.endswith(".npz"):
|
|
87
|
+
with np.load(path) as bundle:
|
|
88
|
+
key = "image" if "image" in bundle else list(bundle)[0]
|
|
89
|
+
return array_to_nifti(bundle[key], affine=affine, spacing=spacing)
|
|
90
|
+
if lower.endswith(".zip"):
|
|
91
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
92
|
+
with zipfile.ZipFile(path) as archive:
|
|
93
|
+
archive.extractall(tmp)
|
|
94
|
+
return dicom_to_nifti(tmp)
|
|
95
|
+
if lower.endswith((".dcm", ".ima")) or _looks_like_dicom(path):
|
|
96
|
+
return dicom_to_nifti(os.path.dirname(path) or ".")
|
|
97
|
+
|
|
98
|
+
# Last resort: let nibabel try (handles .mgz, .hdr/.img, .mnc, ...).
|
|
99
|
+
image = nib.load(path)
|
|
100
|
+
return nib.Nifti1Image(np.asanyarray(image.dataobj), image.affine, None)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def is_dicom_directory(path: str) -> bool:
|
|
104
|
+
"""True for a directory of DICOM slices, i.e. one series rather than a cohort.
|
|
105
|
+
|
|
106
|
+
Only the files directly inside `path` are examined, which is what separates
|
|
107
|
+
a single series from a directory of per-case subdirectories.
|
|
108
|
+
"""
|
|
109
|
+
try:
|
|
110
|
+
entries = sorted(os.listdir(path))
|
|
111
|
+
except OSError:
|
|
112
|
+
return False
|
|
113
|
+
for name in entries[:50]:
|
|
114
|
+
candidate = os.path.join(path, name)
|
|
115
|
+
if os.path.isfile(candidate) and (
|
|
116
|
+
name.lower().endswith((".dcm", ".ima")) or _looks_like_dicom(candidate)
|
|
117
|
+
):
|
|
118
|
+
return True
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _looks_like_dicom(path: str) -> bool:
|
|
123
|
+
try:
|
|
124
|
+
with open(path, "rb") as handle:
|
|
125
|
+
handle.seek(128)
|
|
126
|
+
return handle.read(4) == b"DICM"
|
|
127
|
+
except OSError:
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def array_to_nifti(
|
|
132
|
+
array: np.ndarray,
|
|
133
|
+
affine: Optional[np.ndarray] = None,
|
|
134
|
+
spacing: Optional[Sequence[float]] = None,
|
|
135
|
+
) -> nib.Nifti1Image:
|
|
136
|
+
"""Wrap a raw array, building an affine from `spacing` when given.
|
|
137
|
+
|
|
138
|
+
A bare array has no world coordinates. We default to an identity affine
|
|
139
|
+
(1 mm isotropic, RAS), which is fine for shape-based work but means
|
|
140
|
+
physical-space steps such as resampling are meaningless until you supply
|
|
141
|
+
real spacing.
|
|
142
|
+
"""
|
|
143
|
+
if affine is None:
|
|
144
|
+
affine = np.eye(4)
|
|
145
|
+
if spacing is not None:
|
|
146
|
+
affine[:3, :3] = np.diag([float(s) for s in list(spacing)[:3]])
|
|
147
|
+
return nib.Nifti1Image(np.asarray(array), np.asarray(affine, dtype=float))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ----------------------------------------------------------------------
|
|
151
|
+
# DICOM
|
|
152
|
+
# ----------------------------------------------------------------------
|
|
153
|
+
def dicom_to_nifti(
|
|
154
|
+
dicom_dir: str,
|
|
155
|
+
prefer: str = "auto",
|
|
156
|
+
) -> nib.Nifti1Image:
|
|
157
|
+
"""Convert a directory of DICOM slices into a NIfTI volume, in memory.
|
|
158
|
+
|
|
159
|
+
Uses ``dcm2niix`` when it is on the PATH (it handles more scanner quirks),
|
|
160
|
+
otherwise SimpleITK's series reader. Any files produced by ``dcm2niix``
|
|
161
|
+
live in a temporary directory that is removed before this returns.
|
|
162
|
+
"""
|
|
163
|
+
if prefer not in ("auto", "dcm2niix", "sitk"):
|
|
164
|
+
raise ValueError(f"prefer must be 'auto', 'dcm2niix' or 'sitk', got {prefer!r}")
|
|
165
|
+
|
|
166
|
+
use_dcm2niix = prefer == "dcm2niix" or (
|
|
167
|
+
prefer == "auto" and shutil.which("dcm2niix") is not None
|
|
168
|
+
)
|
|
169
|
+
if use_dcm2niix:
|
|
170
|
+
try:
|
|
171
|
+
return _dicom_to_nifti_dcm2niix(dicom_dir)
|
|
172
|
+
except Exception as error: # noqa: BLE001 - fall through to SimpleITK
|
|
173
|
+
if prefer == "dcm2niix":
|
|
174
|
+
raise
|
|
175
|
+
logger.debug("dcm2niix failed on %s (%s); falling back to SimpleITK",
|
|
176
|
+
dicom_dir, error)
|
|
177
|
+
return _dicom_to_nifti_sitk(dicom_dir)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _dicom_to_nifti_dcm2niix(dicom_dir: str) -> nib.Nifti1Image:
|
|
181
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
182
|
+
subprocess.run(
|
|
183
|
+
["dcm2niix", "-z", "y", "-f", "%j", "-o", tmp, dicom_dir],
|
|
184
|
+
check=True,
|
|
185
|
+
stdout=subprocess.PIPE,
|
|
186
|
+
stderr=subprocess.STDOUT,
|
|
187
|
+
)
|
|
188
|
+
produced = sorted(
|
|
189
|
+
os.path.join(tmp, name)
|
|
190
|
+
for name in os.listdir(tmp)
|
|
191
|
+
if name.lower().endswith(NIFTI_SUFFIXES)
|
|
192
|
+
)
|
|
193
|
+
if not produced:
|
|
194
|
+
raise RuntimeError(f"dcm2niix produced no NIfTI output for {dicom_dir}")
|
|
195
|
+
if len(produced) > 1:
|
|
196
|
+
# A split series (multi-echo, mixed orientations). Keep the volume
|
|
197
|
+
# with the most slices, matching the notebook's fallback rule.
|
|
198
|
+
def slice_count(candidate: str) -> int:
|
|
199
|
+
shape = nib.load(candidate).shape
|
|
200
|
+
return shape[2] if len(shape) > 2 else 0
|
|
201
|
+
|
|
202
|
+
produced.sort(key=slice_count)
|
|
203
|
+
logger.debug("dcm2niix split %s into %d volumes; keeping the thickest",
|
|
204
|
+
dicom_dir, len(produced))
|
|
205
|
+
image = nib.load(produced[-1])
|
|
206
|
+
# Force the data into memory before the temp directory disappears.
|
|
207
|
+
return nib.Nifti1Image(np.asanyarray(image.dataobj), image.affine, image.header)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _dicom_to_nifti_sitk(dicom_dir: str) -> nib.Nifti1Image:
|
|
211
|
+
reader = sitk.ImageSeriesReader()
|
|
212
|
+
series_ids = reader.GetGDCMSeriesIDs(dicom_dir)
|
|
213
|
+
if not series_ids:
|
|
214
|
+
files = reader.GetGDCMSeriesFileNames(dicom_dir)
|
|
215
|
+
if not files:
|
|
216
|
+
raise FileNotFoundError(f"No readable DICOM series in {dicom_dir}")
|
|
217
|
+
else:
|
|
218
|
+
if len(series_ids) > 1:
|
|
219
|
+
logger.warning(
|
|
220
|
+
"%d DICOM series found in %s; using the one with the most slices. "
|
|
221
|
+
"Split the directory by SeriesInstanceUID to control this.",
|
|
222
|
+
len(series_ids), dicom_dir,
|
|
223
|
+
)
|
|
224
|
+
files = max(
|
|
225
|
+
(reader.GetGDCMSeriesFileNames(dicom_dir, uid) for uid in series_ids),
|
|
226
|
+
key=len,
|
|
227
|
+
)
|
|
228
|
+
reader.SetFileNames(files)
|
|
229
|
+
return sitk_to_nifti(reader.Execute())
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def read_dicom_header(path: str):
|
|
233
|
+
"""Read the header of the first DICOM slice in `path` (file or directory)."""
|
|
234
|
+
import pydicom
|
|
235
|
+
|
|
236
|
+
if os.path.isdir(path):
|
|
237
|
+
candidates = sorted(
|
|
238
|
+
os.path.join(root, name)
|
|
239
|
+
for root, _, names in os.walk(path)
|
|
240
|
+
for name in names
|
|
241
|
+
if not name.startswith(".") and name != "LICENSE"
|
|
242
|
+
)
|
|
243
|
+
if not candidates:
|
|
244
|
+
raise FileNotFoundError(f"No files in {path}")
|
|
245
|
+
last_error: Optional[Exception] = None
|
|
246
|
+
for candidate in candidates:
|
|
247
|
+
try:
|
|
248
|
+
return pydicom.dcmread(candidate, stop_before_pixels=True, force=False)
|
|
249
|
+
except Exception as error: # noqa: BLE001 - try the next file
|
|
250
|
+
last_error = error
|
|
251
|
+
raise ValueError(f"No readable DICOM file in {path}: {last_error}")
|
|
252
|
+
return pydicom.dcmread(path, stop_before_pixels=True)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ----------------------------------------------------------------------
|
|
256
|
+
# NIfTI <-> SimpleITK
|
|
257
|
+
# ----------------------------------------------------------------------
|
|
258
|
+
def nifti_to_sitk(image: nib.Nifti1Image) -> sitk.Image:
|
|
259
|
+
"""Convert to SimpleITK, translating the RAS affine into LPS geometry."""
|
|
260
|
+
data = np.asanyarray(image.dataobj)
|
|
261
|
+
ndim = data.ndim
|
|
262
|
+
if ndim not in (2, 3):
|
|
263
|
+
raise ValueError(f"Expected a 2D or 3D image, got {ndim}D with shape {data.shape}")
|
|
264
|
+
|
|
265
|
+
ras_to_lps = np.diag([-1.0, -1.0, 1.0, 1.0])
|
|
266
|
+
lps = ras_to_lps @ image.affine
|
|
267
|
+
|
|
268
|
+
rotation = lps[:ndim, :ndim]
|
|
269
|
+
spacing = np.linalg.norm(rotation, axis=0)
|
|
270
|
+
spacing[spacing == 0] = 1.0
|
|
271
|
+
direction = rotation / spacing
|
|
272
|
+
|
|
273
|
+
out = sitk.GetImageFromArray(np.ascontiguousarray(data.T))
|
|
274
|
+
out.SetSpacing([float(value) for value in spacing])
|
|
275
|
+
out.SetOrigin([float(value) for value in lps[:ndim, 3]])
|
|
276
|
+
try:
|
|
277
|
+
out.SetDirection([float(value) for value in direction.flatten()])
|
|
278
|
+
except RuntimeError:
|
|
279
|
+
# A 2D slice taken out of an oblique 3D volume can have a non-orthonormal
|
|
280
|
+
# 2x2 block. Geometry is not meaningful for that case anyway.
|
|
281
|
+
logger.debug("Non-orthonormal direction matrix; falling back to identity.")
|
|
282
|
+
return out
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def sitk_to_nifti(image: sitk.Image) -> nib.Nifti1Image:
|
|
286
|
+
"""Convert from SimpleITK, translating LPS geometry back into a RAS affine."""
|
|
287
|
+
data = sitk.GetArrayFromImage(image).T
|
|
288
|
+
ndim = image.GetDimension()
|
|
289
|
+
|
|
290
|
+
spacing = np.asarray(image.GetSpacing(), dtype=float)
|
|
291
|
+
direction = np.asarray(image.GetDirection(), dtype=float).reshape(ndim, ndim)
|
|
292
|
+
origin = np.asarray(image.GetOrigin(), dtype=float)
|
|
293
|
+
|
|
294
|
+
lps = np.eye(4)
|
|
295
|
+
lps[:ndim, :ndim] = direction * spacing
|
|
296
|
+
lps[:ndim, 3] = origin
|
|
297
|
+
|
|
298
|
+
lps_to_ras = np.diag([-1.0, -1.0, 1.0, 1.0])
|
|
299
|
+
return nib.Nifti1Image(data, lps_to_ras @ lps)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# ----------------------------------------------------------------------
|
|
303
|
+
# writing
|
|
304
|
+
# ----------------------------------------------------------------------
|
|
305
|
+
def save_image(
|
|
306
|
+
image: nib.Nifti1Image,
|
|
307
|
+
path: str,
|
|
308
|
+
output_format: str = "nifti",
|
|
309
|
+
compress: bool = True,
|
|
310
|
+
) -> str:
|
|
311
|
+
"""Write `image` to `path`, creating parent directories as needed.
|
|
312
|
+
|
|
313
|
+
Returns the path actually written, which may differ from `path` if the
|
|
314
|
+
extension had to be adjusted for `output_format`.
|
|
315
|
+
"""
|
|
316
|
+
_check_output_format(output_format)
|
|
317
|
+
path = resolve_output_path(path, output_format=output_format, compress=compress)
|
|
318
|
+
parent = os.path.dirname(os.path.abspath(path))
|
|
319
|
+
os.makedirs(parent, exist_ok=True)
|
|
320
|
+
|
|
321
|
+
if output_format in ("numpy", "npy"):
|
|
322
|
+
np.save(path, np.asanyarray(image.dataobj))
|
|
323
|
+
else:
|
|
324
|
+
nib.save(image, path)
|
|
325
|
+
return path
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def resolve_output_path(path: str, output_format: str = "nifti", compress: bool = True) -> str:
|
|
329
|
+
"""Give `path` the extension implied by `output_format`."""
|
|
330
|
+
_check_output_format(output_format)
|
|
331
|
+
lower = path.lower()
|
|
332
|
+
if output_format in ("numpy", "npy"):
|
|
333
|
+
if lower.endswith(".npy"):
|
|
334
|
+
return path
|
|
335
|
+
return _strip_image_suffix(path) + ".npy"
|
|
336
|
+
|
|
337
|
+
wanted = ".nii.gz" if compress else ".nii"
|
|
338
|
+
if lower.endswith(wanted):
|
|
339
|
+
return path
|
|
340
|
+
return _strip_image_suffix(path) + wanted
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _check_output_format(output_format: str) -> None:
|
|
344
|
+
"""Reject a format we cannot write, rather than defaulting to NIfTI.
|
|
345
|
+
|
|
346
|
+
Notably ``"dicom"``: DICOM is read-only here. A clipped, resampled,
|
|
347
|
+
z-scored volume is no longer the acquisition the headers describe, so
|
|
348
|
+
writing it back as DICOM would produce files that misrepresent
|
|
349
|
+
themselves.
|
|
350
|
+
"""
|
|
351
|
+
if output_format not in OUTPUT_FORMATS:
|
|
352
|
+
raise ValueError(
|
|
353
|
+
f"output_format must be one of {', '.join(OUTPUT_FORMATS)}, got "
|
|
354
|
+
f"{output_format!r}."
|
|
355
|
+
+ (" DICOM output is not supported; ctkit reads DICOM but writes "
|
|
356
|
+
"NIfTI or .npy." if "dicom" in output_format.lower() else "")
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _strip_image_suffix(path: str) -> str:
|
|
361
|
+
for suffix in (".nii.gz", ".nii", ".npy", ".npz", ".dcm", ".dicom", ".ima"):
|
|
362
|
+
if path.lower().endswith(suffix):
|
|
363
|
+
return path[: -len(suffix)]
|
|
364
|
+
return path
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def infer_series_id(source: Any) -> Optional[str]:
|
|
368
|
+
"""Guess a series identifier from a path.
|
|
369
|
+
|
|
370
|
+
Files laid out as ``<series_id>/imaging.nii.gz`` take the directory name;
|
|
371
|
+
otherwise the filename without image extensions is used.
|
|
372
|
+
"""
|
|
373
|
+
if not isinstance(source, (str, os.PathLike)):
|
|
374
|
+
return None
|
|
375
|
+
path = os.path.abspath(str(source))
|
|
376
|
+
if os.path.isdir(path):
|
|
377
|
+
return os.path.basename(path.rstrip(os.sep))
|
|
378
|
+
|
|
379
|
+
name = _strip_image_suffix(os.path.basename(path))
|
|
380
|
+
parent = os.path.basename(os.path.dirname(path))
|
|
381
|
+
generic = {"imaging", "image", "img", "ct", "volume", "data"}
|
|
382
|
+
if name.lower() in generic and parent:
|
|
383
|
+
return parent
|
|
384
|
+
return name or parent or None
|
ctkit/metadata.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Making sense of series descriptions.
|
|
2
|
+
|
|
3
|
+
TCIA metadata describes what was scanned in free text written by whoever
|
|
4
|
+
configured the scanner: ``"CT ABDOMEN W CO"``, ``"ART PHASE 2.0 B31f"``,
|
|
5
|
+
``"C/A/P"``. These helpers turn that text into the two fields a cohort is
|
|
6
|
+
usually selected on — which body region was imaged, and which contrast phase
|
|
7
|
+
the series belongs to — so a collection can be summarized and subset before
|
|
8
|
+
any pixels are downloaded.
|
|
9
|
+
|
|
10
|
+
The classification is heuristic. Treat it as a starting point for cohort
|
|
11
|
+
selection, not as ground truth.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from typing import Any, Optional
|
|
18
|
+
|
|
19
|
+
#: Body regions, matched against a normalized series or study description.
|
|
20
|
+
BASE_PATTERNS = {
|
|
21
|
+
"chest": r"(chest|thorax|thor|lung|breast|mammo|mammary|axilla|ch\b|pa\b)",
|
|
22
|
+
"abdomen": r"(abdomen|abdom|abdo|abd\b|ab\b|kub)",
|
|
23
|
+
"pelvis": r"(pelvis|pelv|bladder|pel\b|hip\b)",
|
|
24
|
+
"head_neck": r"(skull|head|neck|brain|c spine)",
|
|
25
|
+
"whole_body": r"(pet ct|skull base to mid thigh|whole body)",
|
|
26
|
+
"renal": r"(renal|kidney|kidneys|neph|ureter|urogram|uro\b|pyelo)",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
#: Contrast phases, in priority order — the first match wins, so the specific
|
|
30
|
+
#: phases are tried before the generic "post-contrast".
|
|
31
|
+
PHASE_PATTERNS = {
|
|
32
|
+
"Scout": r"(scout|topogram|surview|locator|scanogram)",
|
|
33
|
+
"Non-contrast": (
|
|
34
|
+
r"(non[_\-\s]?contrast|without contrast|w/o|w o\b|unenhanced|native|c-|i-|"
|
|
35
|
+
r"no contrast|renal colic|stone)"
|
|
36
|
+
),
|
|
37
|
+
"Arterial": r"(arterial|art\b|45 ?sec|60 ?sec|70 ?sec)",
|
|
38
|
+
"Nephrographic": r"(neph|paren|90 ?sec|100 ?sec|100s|120 ?sec)",
|
|
39
|
+
"Delayed": (
|
|
40
|
+
r"(delay|delayed|excret|urogram|3 ?min|5 ?min|8 ?min|10 ?min|12 ?min|"
|
|
41
|
+
r"15 ?min|180 ?sec)"
|
|
42
|
+
),
|
|
43
|
+
"Post-contrast (unspecified phase)": r"(post|with contrast|i\+|c\+|contrast\b)",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#: Per-collection tweaks, for vocabulary that only makes sense in one disease.
|
|
47
|
+
PROJECT_OVERRIDES = {
|
|
48
|
+
"BRCA": {
|
|
49
|
+
"extra_patterns": {"chest": r"\b(breast|mammo|mammary|axilla)\b"},
|
|
50
|
+
"rename": {"chest": "Chest/Breast"},
|
|
51
|
+
},
|
|
52
|
+
"KIRC": {
|
|
53
|
+
"extra_patterns": {"renal": r"\b(renal|kidney|neph|ureter|urogram|stone)\b"},
|
|
54
|
+
"special_rules": "renal",
|
|
55
|
+
},
|
|
56
|
+
"OV": {
|
|
57
|
+
"extra_patterns": {
|
|
58
|
+
"vascular": r"\b(vascular|aorta)\b",
|
|
59
|
+
"cardiac": r"\b(cardiac)\b",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
"BLCA": {
|
|
63
|
+
"extra_patterns": {
|
|
64
|
+
"renal": r"\b(urogram|pyelo|renal|kidney|triphasic|uro)\b",
|
|
65
|
+
"pelvis": r"\b(bladder)\b",
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#: Typical seconds after contrast injection for each phase, used to resolve
|
|
71
|
+
#: series whose description does not say which phase they are.
|
|
72
|
+
PHASE_TIME_RANGES = {
|
|
73
|
+
"Arterial": (15, 50), # commonly 30-35 s
|
|
74
|
+
"Nephrographic": (55, 100), # commonly 65-80 s
|
|
75
|
+
"Delayed": (250, 700), # commonly 300-375 s
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def normalize(text: str) -> str:
|
|
80
|
+
"""Lowercase and reduce punctuation to single spaces."""
|
|
81
|
+
text = text.lower()
|
|
82
|
+
text = re.sub(r"[^\w]+", " ", text)
|
|
83
|
+
return re.sub(r"\s+", " ", text).strip()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def has_tokens(text: str, *tokens: str) -> bool:
|
|
87
|
+
"""True when every token appears as a whole word in `text`."""
|
|
88
|
+
present = text.split()
|
|
89
|
+
return all(token in present for token in tokens)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def categorize_region(description: Any, project: Optional[str] = None) -> str:
|
|
93
|
+
"""Classify a study or series description into a body region.
|
|
94
|
+
|
|
95
|
+
`project` selects extra vocabulary from :data:`PROJECT_OVERRIDES` (pass the
|
|
96
|
+
TCGA suffix, e.g. ``"KIRC"``).
|
|
97
|
+
"""
|
|
98
|
+
if not isinstance(description, str):
|
|
99
|
+
return "Unknown"
|
|
100
|
+
|
|
101
|
+
text = normalize(description)
|
|
102
|
+
flags = {region: bool(re.search(pattern, text))
|
|
103
|
+
for region, pattern in BASE_PATTERNS.items()}
|
|
104
|
+
|
|
105
|
+
if project:
|
|
106
|
+
key = str(project).upper().replace("TCGA-", "").replace("TCGA_", "")
|
|
107
|
+
for region, pattern in PROJECT_OVERRIDES.get(key, {}).get("extra_patterns", {}).items():
|
|
108
|
+
if re.search(pattern, text):
|
|
109
|
+
flags[region] = True
|
|
110
|
+
|
|
111
|
+
# "CAP" and "C/A/P" are shorthand for chest-abdomen-pelvis.
|
|
112
|
+
if re.search(r"\bcap\b", text) or has_tokens(text, "c", "a", "p"):
|
|
113
|
+
flags["chest"] = flags["abdomen"] = flags["pelvis"] = True
|
|
114
|
+
elif has_tokens(text, "a", "p"):
|
|
115
|
+
flags["abdomen"] = flags["pelvis"] = True
|
|
116
|
+
elif has_tokens(text, "c", "a"):
|
|
117
|
+
flags["chest"] = flags["abdomen"] = True
|
|
118
|
+
|
|
119
|
+
if flags.get("whole_body"):
|
|
120
|
+
return "Whole Body"
|
|
121
|
+
|
|
122
|
+
if flags.get("renal"):
|
|
123
|
+
return "Abdomen/Pelvis (Renal)" if (
|
|
124
|
+
flags.get("abdomen") or flags.get("pelvis")
|
|
125
|
+
) else "Renal"
|
|
126
|
+
|
|
127
|
+
if flags.get("chest") and flags.get("abdomen") and flags.get("pelvis"):
|
|
128
|
+
return "Chest/Abdomen/Pelvis"
|
|
129
|
+
if flags.get("abdomen") and flags.get("pelvis"):
|
|
130
|
+
return "Abdomen/Pelvis"
|
|
131
|
+
if flags.get("chest") and flags.get("abdomen"):
|
|
132
|
+
return "Chest/Abdomen"
|
|
133
|
+
|
|
134
|
+
for region in ("chest", "abdomen", "pelvis", "head_neck"):
|
|
135
|
+
if flags.get(region):
|
|
136
|
+
label = region.capitalize()
|
|
137
|
+
if project:
|
|
138
|
+
key = str(project).upper().replace("TCGA-", "").replace("TCGA_", "")
|
|
139
|
+
label = PROJECT_OVERRIDES.get(key, {}).get("rename", {}).get(region, label)
|
|
140
|
+
return label
|
|
141
|
+
|
|
142
|
+
return "Other"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def categorize_phase(description: Any) -> str:
|
|
146
|
+
"""Classify a series description into a contrast phase."""
|
|
147
|
+
if not isinstance(description, str):
|
|
148
|
+
return "Other"
|
|
149
|
+
|
|
150
|
+
text = normalize(description)
|
|
151
|
+
for phase, pattern in PHASE_PATTERNS.items():
|
|
152
|
+
if re.search(pattern, text):
|
|
153
|
+
return phase
|
|
154
|
+
return "Other"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def annotate(metadata, project: Optional[str] = None, description_column: Optional[str] = None):
|
|
158
|
+
"""Add ``region`` and ``phase`` columns to a series metadata table."""
|
|
159
|
+
frame = metadata.copy()
|
|
160
|
+
|
|
161
|
+
if description_column is None:
|
|
162
|
+
for candidate in ("SeriesDescription", "Series Description",
|
|
163
|
+
"StudyDescription", "Study Description", "StudyDesc"):
|
|
164
|
+
if candidate in frame.columns:
|
|
165
|
+
description_column = candidate
|
|
166
|
+
break
|
|
167
|
+
if description_column is None:
|
|
168
|
+
raise KeyError(
|
|
169
|
+
"No description column found. Pass description_column= explicitly "
|
|
170
|
+
f"(columns: {', '.join(map(str, frame.columns))})"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
region_source = next(
|
|
174
|
+
(column for column in ("StudyDescription", "Study Description", "StudyDesc")
|
|
175
|
+
if column in frame.columns),
|
|
176
|
+
description_column,
|
|
177
|
+
)
|
|
178
|
+
frame["region"] = frame[region_source].apply(lambda text: categorize_region(text, project))
|
|
179
|
+
frame["phase"] = frame[description_column].apply(categorize_phase)
|
|
180
|
+
return frame
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def summarize(metadata, project: Optional[str] = None):
|
|
184
|
+
"""Counts of series, studies and patients, broken down several ways.
|
|
185
|
+
|
|
186
|
+
Returns a dict of DataFrames: ``totals``, ``by_modality``,
|
|
187
|
+
``by_modality_and_region`` and ``by_phase``. This is the cohort table you
|
|
188
|
+
look at before deciding what to download.
|
|
189
|
+
"""
|
|
190
|
+
import pandas as pd
|
|
191
|
+
|
|
192
|
+
frame = metadata if "region" in metadata.columns else annotate(metadata, project)
|
|
193
|
+
|
|
194
|
+
series_column = _first_column(frame, "SeriesInstanceUID", "Series UID", "Series Instance UID")
|
|
195
|
+
study_column = _first_column(frame, "StudyInstanceUID", "study_id", "Study UID",
|
|
196
|
+
"Study Instance UID")
|
|
197
|
+
patient_column = _first_column(frame, "PatientID", "patient_id", "Patient ID", "Subject ID")
|
|
198
|
+
|
|
199
|
+
def counts(group_by=None):
|
|
200
|
+
aggregation = {}
|
|
201
|
+
if series_column:
|
|
202
|
+
aggregation["num_series"] = (series_column, "nunique")
|
|
203
|
+
if study_column:
|
|
204
|
+
aggregation["num_studies"] = (study_column, "nunique")
|
|
205
|
+
if patient_column:
|
|
206
|
+
aggregation["num_patients"] = (patient_column, "nunique")
|
|
207
|
+
if not aggregation:
|
|
208
|
+
return pd.DataFrame({"num_rows": [len(frame)]})
|
|
209
|
+
if group_by is None:
|
|
210
|
+
return pd.DataFrame([{
|
|
211
|
+
name: frame[column].nunique() for name, (column, _) in aggregation.items()
|
|
212
|
+
}])
|
|
213
|
+
return (
|
|
214
|
+
frame.groupby(group_by)
|
|
215
|
+
.agg(**aggregation)
|
|
216
|
+
.sort_values(list(aggregation)[0], ascending=False)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
summary = {"totals": counts(), "by_modality": None,
|
|
220
|
+
"by_modality_and_region": None, "by_phase": counts("phase")}
|
|
221
|
+
if "Modality" in frame.columns:
|
|
222
|
+
summary["by_modality"] = counts("Modality")
|
|
223
|
+
summary["by_modality_and_region"] = counts(["Modality", "region"])
|
|
224
|
+
else:
|
|
225
|
+
summary["by_modality_and_region"] = counts("region")
|
|
226
|
+
return summary
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _first_column(frame, *candidates) -> Optional[str]:
|
|
230
|
+
for candidate in candidates:
|
|
231
|
+
if candidate in frame.columns:
|
|
232
|
+
return candidate
|
|
233
|
+
return None
|
ctkit/py.typed
ADDED
|
File without changes
|