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/qc.py ADDED
@@ -0,0 +1,482 @@
1
+ """Quality control: deciding which series are usable.
2
+
3
+ Two families of checks, matching the two points in a project where you can
4
+ cheaply throw data away:
5
+
6
+ * :func:`check_series_metadata` — needs only DICOM headers or a TCIA metadata
7
+ row, so it can run *before* downloading or converting anything. Catches
8
+ localizers, scouts, MIPs, sharp reconstruction kernels and thick slices.
9
+ * :func:`check_volume` — needs the reconstructed volume. Catches 4D series,
10
+ too-few slices, extreme voxel spacing and anisotropic in-plane sampling.
11
+
12
+ Both return a :class:`QCResult`, which is falsy when the series should be
13
+ dropped and carries the reasons why. :func:`check` runs whichever of the two
14
+ applies to what you hand it and merges the outcomes;
15
+ :meth:`~ctkit.Dataset.filter` is the same thing over a cohort.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import os
22
+ import re
23
+ from dataclasses import dataclass, field
24
+ from dataclasses import fields as dataclass_fields
25
+ from dataclasses import replace as dataclass_replace
26
+ from typing import Any, Mapping, Optional, Sequence
27
+
28
+ import nibabel as nib
29
+ import numpy as np
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ #: Series whose description or image type contains any of these are never
34
+ #: usable for analysis: they are scanner-generated previews, not diagnostic
35
+ #: reconstructions.
36
+ BAD_SERIES_KEYWORDS = frozenset({
37
+ "localizer", "survey", "asset", "scout", "cal", "mipseries", "pjn",
38
+ "summary series", "topogram", "mip", "smart prep",
39
+ })
40
+
41
+ #: Sharp/bone reconstruction kernels. Fine for viewing bone, but their noise
42
+ #: texture dominates radiomic features, so they are excluded when texture
43
+ #: features matter.
44
+ SHARP_KERNEL_KEYWORDS = ("B5", "B6", "B7", "B8", "bone")
45
+
46
+
47
+ @dataclass
48
+ class QCResult:
49
+ """Outcome of a quality check.
50
+
51
+ Truthy when the series passed. ``reasons`` explains every failure, and
52
+ ``stats`` carries the measurements taken along the way so they can be
53
+ tabulated even for series that passed.
54
+ """
55
+
56
+ passed: bool = True
57
+ reasons: list = field(default_factory=list)
58
+ stats: dict = field(default_factory=dict)
59
+ series_id: Optional[str] = None
60
+
61
+ def __bool__(self) -> bool:
62
+ return self.passed
63
+
64
+ def fail(self, reason: str) -> "QCResult":
65
+ self.passed = False
66
+ self.reasons.append(reason)
67
+ return self
68
+
69
+ @property
70
+ def reason(self) -> str:
71
+ """All failure reasons as one string (empty when passed)."""
72
+ return "; ".join(self.reasons)
73
+
74
+ def to_dict(self) -> dict:
75
+ data = {"series_id": self.series_id, "passed": self.passed, "reason": self.reason}
76
+ data.update(self.stats)
77
+ return data
78
+
79
+ def __repr__(self) -> str:
80
+ verdict = "PASS" if self.passed else f"FAIL ({self.reason})"
81
+ name = f"{self.series_id}: " if self.series_id else ""
82
+ return f"<QCResult {name}{verdict}>"
83
+
84
+
85
+ @dataclass
86
+ class QCCriteria:
87
+ """Thresholds for accepting a series.
88
+
89
+ Defaults are the values used in the TCIA CT protocol. Set any field to
90
+ ``None`` to disable that individual check.
91
+ """
92
+
93
+ # --- metadata-level ---
94
+ modality: Optional[str] = "CT"
95
+ min_slices: Optional[int] = 25
96
+ max_slice_thickness: Optional[float] = 10.0
97
+ exclude_keywords: Sequence[str] = tuple(sorted(BAD_SERIES_KEYWORDS))
98
+ exclude_sharp_kernels: bool = False
99
+
100
+ # --- volume-level ---
101
+ reject_4d: bool = True
102
+ max_spacing: Optional[float] = 20.0
103
+ max_in_plane_anisotropy: Optional[float] = 4.0
104
+ require_thickest_axis_is_superior_inferior: bool = False
105
+
106
+ @classmethod
107
+ def for_radiomics(cls, **overrides: Any) -> "QCCriteria":
108
+ """Stricter criteria for texture analysis: drops sharp kernels too."""
109
+ params: dict = dict(exclude_sharp_kernels=True)
110
+ params.update(overrides)
111
+ return cls(**params)
112
+
113
+ @classmethod
114
+ def permissive(cls, **overrides: Any) -> "QCCriteria":
115
+ """Only drop series that cannot be processed at all."""
116
+ params: dict = dict(
117
+ modality=None,
118
+ min_slices=1,
119
+ max_slice_thickness=None,
120
+ max_spacing=None,
121
+ max_in_plane_anisotropy=None,
122
+ exclude_keywords=(),
123
+ )
124
+ params.update(overrides)
125
+ return cls(**params)
126
+
127
+
128
+ #: The presets, by the name you can pass instead of a `QCCriteria`.
129
+ PRESETS = {
130
+ "default": QCCriteria,
131
+ "radiomics": QCCriteria.for_radiomics,
132
+ "permissive": QCCriteria.permissive,
133
+ }
134
+
135
+
136
+ def resolve_criteria(
137
+ criteria: Optional[Any] = None, **thresholds: Any
138
+ ) -> QCCriteria:
139
+ """A :class:`QCCriteria` from an object, a preset name, or bare thresholds.
140
+
141
+ This is what lets ``filter(min_slices=25)`` and ``filter("radiomics")``
142
+ mean the same thing as building the object yourself.
143
+ """
144
+ if isinstance(criteria, str):
145
+ if criteria not in PRESETS:
146
+ raise ValueError(
147
+ f"Unknown quality control preset {criteria!r}; "
148
+ f"expected one of {', '.join(sorted(PRESETS))}."
149
+ )
150
+ base = PRESETS[criteria]()
151
+ else:
152
+ base = criteria or QCCriteria()
153
+
154
+ if not thresholds:
155
+ return base
156
+ known = {item.name for item in dataclass_fields(QCCriteria)}
157
+ unknown = sorted(set(thresholds) - known)
158
+ if unknown:
159
+ raise TypeError(
160
+ f"Unknown quality control threshold(s): {', '.join(unknown)}. "
161
+ f"Available: {', '.join(sorted(known))}."
162
+ )
163
+ return dataclass_replace(base, **thresholds)
164
+
165
+
166
+ # ----------------------------------------------------------------------
167
+ # metadata-level checks
168
+ # ----------------------------------------------------------------------
169
+ def check_series_metadata(
170
+ metadata: Any,
171
+ criteria: Optional[QCCriteria] = None,
172
+ series_id: Optional[str] = None,
173
+ ) -> QCResult:
174
+ """Check a series from headers alone — no pixel data required.
175
+
176
+ `metadata` may be a :class:`pydicom.Dataset`, a mapping (a row of TCIA
177
+ ``getSeries`` output or of a metadata CSV), or a path to a DICOM file or
178
+ directory.
179
+ """
180
+ criteria = criteria or QCCriteria()
181
+ result = QCResult(series_id=series_id)
182
+
183
+ if isinstance(metadata, (str, os.PathLike)):
184
+ return _check_dicom_path(str(metadata), criteria, result)
185
+
186
+ fields = _extract_metadata_fields(metadata)
187
+ result.stats.update({k: v for k, v in fields.items() if v is not None})
188
+ _apply_metadata_criteria(fields, criteria, result)
189
+ return result
190
+
191
+
192
+ def _check_dicom_path(path: str, criteria: QCCriteria, result: QCResult) -> QCResult:
193
+ from .io import read_dicom_header
194
+
195
+ if os.path.isdir(path):
196
+ slices = [
197
+ name for name in os.listdir(path)
198
+ if not name.startswith(".") and name != "LICENSE"
199
+ ]
200
+ if criteria.min_slices is not None and len(slices) < criteria.min_slices:
201
+ result.fail(f"too few DICOM files ({len(slices)} < {criteria.min_slices})")
202
+ result.stats["n_files"] = len(slices)
203
+ if not slices:
204
+ return result
205
+
206
+ try:
207
+ header = read_dicom_header(path)
208
+ except Exception as error: # noqa: BLE001 - unreadable is a QC failure
209
+ return result.fail(f"unreadable DICOM: {error}")
210
+
211
+ fields = _extract_metadata_fields(header)
212
+ # A directory listing counts slices more reliably than the header does.
213
+ if "n_files" in result.stats:
214
+ fields["image_count"] = result.stats["n_files"]
215
+ result.stats.update({k: v for k, v in fields.items() if v is not None})
216
+ _apply_metadata_criteria(fields, criteria, result, count_checked="n_files" in result.stats)
217
+ return result
218
+
219
+
220
+ def _extract_metadata_fields(metadata: Any) -> dict:
221
+ """Pull the fields we filter on out of a DICOM header or a metadata row."""
222
+
223
+ def lookup(*names: str) -> Any:
224
+ for name in names:
225
+ if isinstance(metadata, Mapping):
226
+ if name in metadata and _is_present(metadata[name]):
227
+ return metadata[name]
228
+ else:
229
+ value = getattr(metadata, name, None)
230
+ if _is_present(value):
231
+ return value
232
+ return None
233
+
234
+ text_parts = []
235
+ for value in (
236
+ lookup("SeriesDescription", "Series Description", "series_description"),
237
+ lookup("ProtocolName", "Protocol Name", "protocol_name"),
238
+ lookup("StudyDescription", "Study Description", "study_description"),
239
+ lookup("ImageType", "Image Type", "image_type"),
240
+ ):
241
+ if value is None:
242
+ continue
243
+ if isinstance(value, str):
244
+ text_parts.extend(value.split("\\"))
245
+ elif isinstance(value, (list, tuple)):
246
+ text_parts.extend(str(item) for item in value)
247
+ else:
248
+ text_parts.append(str(value))
249
+
250
+ thickness = lookup("SliceThickness", "Slice Thickness", "slice_thickness")
251
+ count = lookup(
252
+ "ImageCount", "Number of Images", "Number of Images Original",
253
+ "image_count", "NumberOfFrames",
254
+ )
255
+
256
+ return {
257
+ "modality": _as_str(lookup("Modality", "modality")),
258
+ "series_description": _as_str(
259
+ lookup("SeriesDescription", "Series Description", "series_description")
260
+ ),
261
+ "slice_thickness": _as_float(thickness),
262
+ "image_count": _as_int(count),
263
+ "_text": [part.lower() for part in text_parts if part],
264
+ }
265
+
266
+
267
+ def _apply_metadata_criteria(
268
+ fields: dict,
269
+ criteria: QCCriteria,
270
+ result: QCResult,
271
+ count_checked: bool = False,
272
+ ) -> None:
273
+ text = fields.get("_text") or []
274
+ result.stats.pop("_text", None)
275
+
276
+ if criteria.modality is not None and fields.get("modality"):
277
+ if str(fields["modality"]).upper() != criteria.modality.upper():
278
+ result.fail(f"modality is {fields['modality']}, not {criteria.modality}")
279
+
280
+ for keyword in criteria.exclude_keywords or ():
281
+ if any(_mentions(part, keyword) for part in text):
282
+ result.fail(f"excluded keyword {keyword!r} in series description/image type")
283
+ break
284
+
285
+ if criteria.exclude_sharp_kernels:
286
+ for keyword in SHARP_KERNEL_KEYWORDS:
287
+ if any(keyword.lower() in part for part in text):
288
+ result.fail(f"sharp reconstruction kernel {keyword!r}")
289
+ break
290
+
291
+ thickness = fields.get("slice_thickness")
292
+ if criteria.max_slice_thickness is not None and thickness is not None:
293
+ if thickness > criteria.max_slice_thickness:
294
+ result.fail(
295
+ f"slice thickness {thickness:g} mm > {criteria.max_slice_thickness:g} mm"
296
+ )
297
+
298
+ count = fields.get("image_count")
299
+ if not count_checked and criteria.min_slices is not None and count is not None:
300
+ if count < criteria.min_slices:
301
+ result.fail(f"too few slices ({count} < {criteria.min_slices})")
302
+
303
+
304
+ # ----------------------------------------------------------------------
305
+ # volume-level checks
306
+ # ----------------------------------------------------------------------
307
+ def check_volume(
308
+ image: Any,
309
+ criteria: Optional[QCCriteria] = None,
310
+ series_id: Optional[str] = None,
311
+ ) -> QCResult:
312
+ """Check a reconstructed volume: dimensionality, slice count and spacing."""
313
+ criteria = criteria or QCCriteria()
314
+ result = QCResult(series_id=series_id)
315
+
316
+ if image is None:
317
+ return result.fail("image is missing")
318
+
319
+ if not isinstance(image, nib.Nifti1Image):
320
+ from .io import load_image
321
+
322
+ try:
323
+ image = load_image(image)
324
+ except Exception as error: # noqa: BLE001 - unloadable is a QC failure
325
+ return result.fail(f"could not load image: {error}")
326
+
327
+ try:
328
+ shape = image.shape
329
+ zooms = tuple(float(z) for z in image.header.get_zooms()[: len(shape)])
330
+ except Exception as error: # noqa: BLE001 - corrupt header is a QC failure
331
+ return result.fail(f"could not read image header: {error}")
332
+
333
+ result.stats["shape"] = tuple(int(s) for s in shape)
334
+ result.stats["spacing"] = zooms
335
+ orientation = nib.orientations.aff2axcodes(image.affine)
336
+ result.stats["orientation"] = "".join(str(code) for code in orientation)
337
+
338
+ if len(shape) > 3:
339
+ result.stats["is_4d"] = True
340
+ if criteria.reject_4d:
341
+ result.fail(
342
+ f"4D volume with shape {tuple(shape)} — the DICOM series probably "
343
+ "mixes phases or time points and should be split first"
344
+ )
345
+ return result
346
+ else:
347
+ result.stats["is_4d"] = False
348
+
349
+ n_slices = int(shape[2]) if len(shape) > 2 else 1
350
+ result.stats["n_slices"] = n_slices
351
+ if criteria.min_slices is not None and n_slices < criteria.min_slices:
352
+ result.fail(f"only {n_slices} slices (< {criteria.min_slices})")
353
+
354
+ if zooms:
355
+ max_spacing = max(zooms)
356
+ result.stats["max_spacing"] = max_spacing
357
+ if criteria.max_spacing is not None and max_spacing > criteria.max_spacing:
358
+ result.fail(f"voxel spacing {max_spacing:g} mm > {criteria.max_spacing:g} mm")
359
+
360
+ if len(shape) >= 3:
361
+ canonical_zooms = np.asarray(
362
+ nib.as_closest_canonical(image).header.get_zooms()[:3], dtype=float
363
+ )
364
+ in_plane = canonical_zooms[:2]
365
+ anisotropy = (
366
+ float(in_plane.max() / in_plane.min()) if in_plane.min() > 0 else np.inf
367
+ )
368
+ result.stats["in_plane_anisotropy"] = anisotropy
369
+ if (criteria.max_in_plane_anisotropy is not None
370
+ and anisotropy > criteria.max_in_plane_anisotropy):
371
+ result.fail(
372
+ f"in-plane anisotropy {anisotropy:.2f} > "
373
+ f"{criteria.max_in_plane_anisotropy:g} — likely a reformat or an "
374
+ "off-axis acquisition"
375
+ )
376
+
377
+ thickest_axis = int(np.argmax(zooms))
378
+ thickest_code = str(orientation[thickest_axis]) if thickest_axis < len(orientation) else ""
379
+ in_si = thickest_code in ("S", "I")
380
+ result.stats["thickest_axis_is_si"] = in_si
381
+ if criteria.require_thickest_axis_is_superior_inferior and not in_si:
382
+ result.fail(
383
+ f"thickest axis is {thickest_code or 'unknown'}, not superior-inferior "
384
+ "(the series is probably coronal or sagittal)"
385
+ )
386
+
387
+ return result
388
+
389
+
390
+ def check(
391
+ image: Any = None,
392
+ criteria: Optional[QCCriteria] = None,
393
+ series_id: Optional[str] = None,
394
+ metadata: Any = None,
395
+ ) -> QCResult:
396
+ """Run every applicable check and merge the outcomes into one result.
397
+
398
+ `image` is a volume, a path to one, or a DICOM directory; `metadata` is a
399
+ DICOM header, a metadata row, a path, or a list of those. A DICOM directory
400
+ passed as `image` is used as a metadata source as well, since its headers
401
+ and file count are the cheapest checks available.
402
+
403
+ Pass ``image=None`` to check metadata alone — no pixel data is read, which
404
+ is what makes it usable before a collection has been downloaded. With
405
+ nothing to check the result passes: quality control drops series that are
406
+ demonstrably unusable, not series nothing is known about.
407
+ """
408
+ criteria = criteria or QCCriteria()
409
+
410
+ sources = list(metadata) if isinstance(metadata, (list, tuple)) else [metadata]
411
+ if isinstance(image, (str, os.PathLike)) and os.path.isdir(str(image)):
412
+ sources.append(str(image))
413
+
414
+ results = [
415
+ check_series_metadata(source, criteria, series_id=series_id)
416
+ for source in sources
417
+ if source is not None and not (isinstance(source, Mapping) and not source)
418
+ ]
419
+ if image is not None:
420
+ results.append(check_volume(image, criteria, series_id=series_id))
421
+
422
+ if not results:
423
+ logger.debug("%s: nothing to check.", series_id or "image")
424
+ return QCResult(series_id=series_id)
425
+ return merge(results, series_id=series_id)
426
+
427
+
428
+ def merge(results: Sequence[QCResult], series_id: Optional[str] = None) -> QCResult:
429
+ """Combine several results: passes only if all of them pass."""
430
+ merged = QCResult(series_id=series_id)
431
+ for result in results:
432
+ merged.passed = merged.passed and result.passed
433
+ merged.reasons.extend(result.reasons)
434
+ merged.stats.update(result.stats)
435
+ if merged.series_id is None:
436
+ merged.series_id = result.series_id
437
+ return merged
438
+
439
+
440
+ # ----------------------------------------------------------------------
441
+ # helpers
442
+ # ----------------------------------------------------------------------
443
+ def _mentions(text: str, keyword: str) -> bool:
444
+ """Whole-word keyword match.
445
+
446
+ Series descriptions are terse and abbreviated, so several of the keywords
447
+ are short (``cal``, ``mip``, ``pjn``). Matched as bare substrings they hit
448
+ innocent words — ``cal`` is inside ``cervical`` and ``apical`` — and drop
449
+ usable series. Word boundaries keep the abbreviations usable.
450
+ """
451
+ return re.search(rf"\b{re.escape(keyword.lower())}\b", text) is not None
452
+
453
+
454
+ def _is_present(value: Any) -> bool:
455
+ if value is None:
456
+ return False
457
+ try:
458
+ if isinstance(value, float) and np.isnan(value):
459
+ return False
460
+ except TypeError:
461
+ pass
462
+ return not (isinstance(value, str) and not value.strip())
463
+
464
+
465
+ def _as_str(value: Any) -> Optional[str]:
466
+ if not _is_present(value):
467
+ return None
468
+ return str(value)
469
+
470
+
471
+ def _as_float(value: Any) -> Optional[float]:
472
+ if not _is_present(value):
473
+ return None
474
+ try:
475
+ return float(value)
476
+ except (TypeError, ValueError):
477
+ return None
478
+
479
+
480
+ def _as_int(value: Any) -> Optional[int]:
481
+ number = _as_float(value)
482
+ return None if number is None else int(number)