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