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