mspdetect 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.
- mspdetect/__init__.py +51 -0
- mspdetect/_version.py +24 -0
- mspdetect/batch.py +284 -0
- mspdetect/cli.py +58 -0
- mspdetect/config.py +256 -0
- mspdetect/detect.py +197 -0
- mspdetect/io.py +174 -0
- mspdetect/metrics.py +162 -0
- mspdetect/optimize.py +175 -0
- mspdetect/plane.py +155 -0
- mspdetect/preprocessing.py +181 -0
- mspdetect/results.py +229 -0
- mspdetect/viz.py +507 -0
- mspdetect-0.1.0.dist-info/METADATA +122 -0
- mspdetect-0.1.0.dist-info/RECORD +19 -0
- mspdetect-0.1.0.dist-info/WHEEL +5 -0
- mspdetect-0.1.0.dist-info/entry_points.txt +2 -0
- mspdetect-0.1.0.dist-info/licenses/LICENSE +21 -0
- mspdetect-0.1.0.dist-info/top_level.txt +1 -0
mspdetect/__init__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""mspdetect: automatic mid-sagittal plane detection in head and neck planning CTs.
|
|
2
|
+
|
|
3
|
+
Typical use::
|
|
4
|
+
|
|
5
|
+
import mspdetect as msp
|
|
6
|
+
|
|
7
|
+
case = msp.load_case("data/patient01", structures={"GTVp": "GTVp", "Body": "External"})
|
|
8
|
+
result = msp.detect_msp(case)
|
|
9
|
+
metrics = msp.compute_metrics(case, result.plane, structures=["Parotid_L"])
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
__version__ = version("mspdetect")
|
|
16
|
+
except PackageNotFoundError: # not installed
|
|
17
|
+
__version__ = "unknown"
|
|
18
|
+
|
|
19
|
+
from mspdetect.config import (
|
|
20
|
+
BatchConfig,
|
|
21
|
+
CropConfig,
|
|
22
|
+
DataConfig,
|
|
23
|
+
DetectionConfig,
|
|
24
|
+
MetricsConfig,
|
|
25
|
+
OptimizerConfig,
|
|
26
|
+
)
|
|
27
|
+
from mspdetect.detect import MSPResult, detect_msp
|
|
28
|
+
from mspdetect.io import Case, load_case
|
|
29
|
+
from mspdetect.metrics import compute_metrics, side_volumes, target_metrics
|
|
30
|
+
from mspdetect.plane import Plane
|
|
31
|
+
from mspdetect.results import load_result, save_result
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"BatchConfig",
|
|
35
|
+
"Case",
|
|
36
|
+
"CropConfig",
|
|
37
|
+
"DataConfig",
|
|
38
|
+
"DetectionConfig",
|
|
39
|
+
"MSPResult",
|
|
40
|
+
"MetricsConfig",
|
|
41
|
+
"OptimizerConfig",
|
|
42
|
+
"Plane",
|
|
43
|
+
"__version__",
|
|
44
|
+
"compute_metrics",
|
|
45
|
+
"detect_msp",
|
|
46
|
+
"load_case",
|
|
47
|
+
"load_result",
|
|
48
|
+
"save_result",
|
|
49
|
+
"side_volumes",
|
|
50
|
+
"target_metrics",
|
|
51
|
+
]
|
mspdetect/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
mspdetect/batch.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Processing many cases, as used by the command line interface.
|
|
2
|
+
|
|
3
|
+
Every case is processed independently; a failing case is recorded in a failures
|
|
4
|
+
table and does not stop the run. Results are written to ``<output>/<case>/``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import csv
|
|
10
|
+
import logging
|
|
11
|
+
import multiprocessing
|
|
12
|
+
from collections.abc import Callable, Iterable
|
|
13
|
+
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from tqdm import tqdm
|
|
17
|
+
from tqdm.contrib.logging import logging_redirect_tqdm
|
|
18
|
+
|
|
19
|
+
from mspdetect import __version__
|
|
20
|
+
from mspdetect.config import BatchConfig
|
|
21
|
+
from mspdetect.detect import detect_msp
|
|
22
|
+
from mspdetect.io import load_case
|
|
23
|
+
from mspdetect.metrics import compute_metrics
|
|
24
|
+
from mspdetect.results import (
|
|
25
|
+
MANUAL_FILE,
|
|
26
|
+
SUMMARY_FILE,
|
|
27
|
+
load_manual_plane,
|
|
28
|
+
load_result,
|
|
29
|
+
read_summary,
|
|
30
|
+
save_result,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
RESULTS_TABLE = "msp_results.csv"
|
|
36
|
+
FAILURES_TABLE = "failures.csv"
|
|
37
|
+
METRICS_TABLE = "metrics.csv"
|
|
38
|
+
METRICS_FAILURES_TABLE = "metrics_failures.csv"
|
|
39
|
+
|
|
40
|
+
# Structures used by the detection itself (besides the crop reference)
|
|
41
|
+
DETECTION_STRUCTURES = ("Body", "Mandible")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def list_cases(config: BatchConfig) -> list[str]:
|
|
45
|
+
"""Case names (paths relative to ``data.root``) to process."""
|
|
46
|
+
root, patients = config.data.root, config.data.patients
|
|
47
|
+
if not root.is_dir():
|
|
48
|
+
raise FileNotFoundError(f"data.root is not a directory: {root}")
|
|
49
|
+
if patients is None:
|
|
50
|
+
return sorted(p.name for p in root.iterdir() if p.is_dir() and not p.name.startswith("."))
|
|
51
|
+
if isinstance(patients, Path):
|
|
52
|
+
lines = patients.read_text().splitlines()
|
|
53
|
+
return [line.strip() for line in lines if line.strip() and not line.startswith("#")]
|
|
54
|
+
return list(patients)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _structure_files(config: BatchConfig, names: Iterable[str]) -> dict:
|
|
58
|
+
return {n: config.data.structures[n] for n in names if n in config.data.structures}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _detection_structures(config: BatchConfig) -> dict:
|
|
62
|
+
names = [*DETECTION_STRUCTURES]
|
|
63
|
+
if config.detection.crop.reference is not None:
|
|
64
|
+
names.append(config.detection.crop.reference)
|
|
65
|
+
if config.detection.crop.reference not in config.data.structures:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"Crop reference '{config.detection.crop.reference}' is not in data.structures",
|
|
68
|
+
)
|
|
69
|
+
return _structure_files(config, names)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _detect_one(case_name: str, config: BatchConfig) -> dict:
|
|
73
|
+
"""Detect and save the plane of one case; returns a status row."""
|
|
74
|
+
out_dir = config.output / case_name
|
|
75
|
+
fingerprint = config.detection.fingerprint()
|
|
76
|
+
if (out_dir / SUMMARY_FILE).exists() and not config.overwrite:
|
|
77
|
+
existing = read_summary(out_dir)
|
|
78
|
+
if existing.get("config_fingerprint") != fingerprint:
|
|
79
|
+
raise RuntimeError(
|
|
80
|
+
"existing result was computed with different detection settings; "
|
|
81
|
+
"use another output directory or set overwrite: true",
|
|
82
|
+
)
|
|
83
|
+
if existing.get("version") != __version__:
|
|
84
|
+
logger.warning(
|
|
85
|
+
"%s: existing result from mspdetect %s (now %s) is kept",
|
|
86
|
+
case_name,
|
|
87
|
+
existing.get("version"),
|
|
88
|
+
__version__,
|
|
89
|
+
)
|
|
90
|
+
return {"case_id": case_name, "status": "existing"}
|
|
91
|
+
|
|
92
|
+
case = load_case(
|
|
93
|
+
config.data.root / case_name,
|
|
94
|
+
image=config.data.image,
|
|
95
|
+
structures=_detection_structures(config),
|
|
96
|
+
case_id=case_name,
|
|
97
|
+
)
|
|
98
|
+
save_result(detect_msp(case, config.detection), out_dir)
|
|
99
|
+
return {"case_id": case_name, "status": "done"}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _metrics_starts(result, which: str) -> list[int]:
|
|
103
|
+
"""Indices of the starts whose planes are evaluated for ``metrics.plane``."""
|
|
104
|
+
if which == "first_start":
|
|
105
|
+
return [0]
|
|
106
|
+
if which == "best":
|
|
107
|
+
return [result.best_start]
|
|
108
|
+
return list(range(len(result.starts)))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _metrics_one(case_name: str, config: BatchConfig) -> list[dict]:
|
|
112
|
+
"""Compute the metrics of one case from its saved plane(s); one row per plane.
|
|
113
|
+
|
|
114
|
+
A manual correction (``manual.json``) replaces the detected plane(s).
|
|
115
|
+
"""
|
|
116
|
+
result = load_result(config.output / case_name)
|
|
117
|
+
manual = load_manual_plane(config.output / case_name)
|
|
118
|
+
names = [*config.metrics.structures]
|
|
119
|
+
if config.metrics.target is not None:
|
|
120
|
+
names.append(config.metrics.target)
|
|
121
|
+
case = load_case(
|
|
122
|
+
config.data.root / case_name,
|
|
123
|
+
image=config.data.image,
|
|
124
|
+
structures=_structure_files(config, names),
|
|
125
|
+
case_id=case_name,
|
|
126
|
+
)
|
|
127
|
+
slices = result.crop_slices if config.metrics.volume_region == "slab" else None
|
|
128
|
+
if manual is not None:
|
|
129
|
+
planes = [("manual", manual.plane)]
|
|
130
|
+
else:
|
|
131
|
+
planes = [
|
|
132
|
+
(i, result.starts[i].final) for i in _metrics_starts(result, config.metrics.plane)
|
|
133
|
+
]
|
|
134
|
+
rows = []
|
|
135
|
+
for start, plane in planes:
|
|
136
|
+
row = compute_metrics(
|
|
137
|
+
case,
|
|
138
|
+
plane,
|
|
139
|
+
config.metrics.structures,
|
|
140
|
+
config.metrics.target,
|
|
141
|
+
slices,
|
|
142
|
+
)
|
|
143
|
+
rows.append(
|
|
144
|
+
{
|
|
145
|
+
"case_id": row.pop("case_id"),
|
|
146
|
+
"plane_source": "detected" if manual is None else "manual",
|
|
147
|
+
"plane": config.metrics.plane,
|
|
148
|
+
"start": start,
|
|
149
|
+
"is_first_start": start == 0,
|
|
150
|
+
"is_best": start == result.best_start,
|
|
151
|
+
"volume_region": config.metrics.volume_region,
|
|
152
|
+
**row,
|
|
153
|
+
},
|
|
154
|
+
)
|
|
155
|
+
return rows
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _safe(
|
|
159
|
+
func: Callable, case_name: str, config: BatchConfig
|
|
160
|
+
) -> tuple[str, dict | list[dict] | None, str | None]:
|
|
161
|
+
try:
|
|
162
|
+
return case_name, func(case_name, config), None
|
|
163
|
+
except Exception as e: # noqa: BLE001 - record any failure and continue with other cases
|
|
164
|
+
logger.exception("%s failed", case_name)
|
|
165
|
+
return case_name, None, f"{type(e).__name__}: {e}"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _run_all(func: Callable, cases: list[str], config: BatchConfig, description: str):
|
|
169
|
+
"""``(case, row, error)`` for every case, in the order of ``cases``.
|
|
170
|
+
|
|
171
|
+
Cases run in parallel if ``n_jobs > 1``; a progress bar counts finished cases.
|
|
172
|
+
"""
|
|
173
|
+
outcomes = {}
|
|
174
|
+
with logging_redirect_tqdm(), tqdm(total=len(cases), desc=description, unit="case") as bar:
|
|
175
|
+
if config.n_jobs == 1:
|
|
176
|
+
finished = (_safe(func, c, config) for c in cases)
|
|
177
|
+
pool = None
|
|
178
|
+
else:
|
|
179
|
+
# "spawn": forking a process with running threads (e.g. tqdm's) can deadlock
|
|
180
|
+
pool = ProcessPoolExecutor(
|
|
181
|
+
max_workers=config.n_jobs, mp_context=multiprocessing.get_context("spawn")
|
|
182
|
+
)
|
|
183
|
+
futures = [pool.submit(_safe, func, c, config) for c in cases]
|
|
184
|
+
finished = (f.result() for f in as_completed(futures))
|
|
185
|
+
try:
|
|
186
|
+
for case_name, row, error in finished:
|
|
187
|
+
outcomes[case_name] = (case_name, row, error)
|
|
188
|
+
bar.update()
|
|
189
|
+
finally:
|
|
190
|
+
if pool is not None:
|
|
191
|
+
pool.shutdown(cancel_futures=True)
|
|
192
|
+
return [outcomes[c] for c in cases]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def write_table(path: Path, rows: list[dict], columns: Iterable[str] = ()) -> None:
|
|
196
|
+
"""Write rows (dicts) as CSV; columns are ``columns`` followed by any other keys."""
|
|
197
|
+
columns = list(dict.fromkeys([*columns, *(k for row in rows for k in row)]))
|
|
198
|
+
with path.open("w", newline="") as f:
|
|
199
|
+
writer = csv.DictWriter(f, fieldnames=columns)
|
|
200
|
+
writer.writeheader()
|
|
201
|
+
writer.writerows(rows)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _summary_row(case_name: str, summary: dict, config_output: Path) -> dict:
|
|
205
|
+
plane = summary["plane"]
|
|
206
|
+
return {
|
|
207
|
+
"case_id": case_name,
|
|
208
|
+
"normal_x": plane["normal"][0],
|
|
209
|
+
"normal_y": plane["normal"][1],
|
|
210
|
+
"normal_z": plane["normal"][2],
|
|
211
|
+
"offset_mm": plane["offset_mm"],
|
|
212
|
+
"azimuth_deg": plane["azimuth_deg"],
|
|
213
|
+
"polar_deg": plane["polar_deg"],
|
|
214
|
+
"objective": summary["objective"],
|
|
215
|
+
"quadratic": summary["quadratic"],
|
|
216
|
+
"converged": summary["converged"],
|
|
217
|
+
"n_starts": len(summary["starts"]),
|
|
218
|
+
"best_start": summary["best_start"],
|
|
219
|
+
"first_start_agrees": summary["first_start_agrees"] if len(summary["starts"]) > 1 else "",
|
|
220
|
+
"first_start_angle_deg": summary["first_start_angle_deg"]
|
|
221
|
+
if len(summary["starts"]) > 1
|
|
222
|
+
else "",
|
|
223
|
+
"first_start_distance_mm": summary["first_start_distance_mm"]
|
|
224
|
+
if len(summary["starts"]) > 1
|
|
225
|
+
else "",
|
|
226
|
+
"runtime_s": summary["runtime_s"],
|
|
227
|
+
"version": summary["version"],
|
|
228
|
+
"manual_correction": (config_output / case_name / MANUAL_FILE).exists(),
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def run_detection(config: BatchConfig) -> tuple[int, int]:
|
|
233
|
+
"""Detect the plane of all cases; returns ``(n_succeeded, n_failed)``.
|
|
234
|
+
|
|
235
|
+
Writes ``msp_results.csv`` (all cases with a result) and ``failures.csv``.
|
|
236
|
+
"""
|
|
237
|
+
cases = list_cases(config)
|
|
238
|
+
_detection_structures(config) # fail early on configuration errors
|
|
239
|
+
config.output.mkdir(parents=True, exist_ok=True)
|
|
240
|
+
logger.info("Detecting the MSP of %d case(s) with %d job(s)", len(cases), config.n_jobs)
|
|
241
|
+
|
|
242
|
+
rows, failures = [], []
|
|
243
|
+
for case_name, _, error in _run_all(_detect_one, cases, config, "Detecting MSP"):
|
|
244
|
+
if error:
|
|
245
|
+
failures.append({"case_id": case_name, "error": error})
|
|
246
|
+
else:
|
|
247
|
+
rows.append(
|
|
248
|
+
_summary_row(case_name, read_summary(config.output / case_name), config.output)
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
write_table(config.output / RESULTS_TABLE, rows)
|
|
252
|
+
write_table(config.output / FAILURES_TABLE, failures, columns=("case_id", "error"))
|
|
253
|
+
logger.info(
|
|
254
|
+
"Done: %d succeeded, %d failed (see %s)",
|
|
255
|
+
len(rows),
|
|
256
|
+
len(failures),
|
|
257
|
+
FAILURES_TABLE,
|
|
258
|
+
)
|
|
259
|
+
return len(rows), len(failures)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def run_metrics(config: BatchConfig) -> tuple[int, int]:
|
|
263
|
+
"""Compute metrics for all cases with a detected plane.
|
|
264
|
+
|
|
265
|
+
Planes are read from ``output``; ``metrics.csv`` and ``metrics_failures.csv`` are
|
|
266
|
+
written to ``metrics.output`` (default: ``output``).
|
|
267
|
+
"""
|
|
268
|
+
cases = [c for c in list_cases(config) if (config.output / c / SUMMARY_FILE).exists()]
|
|
269
|
+
out_dir = config.metrics_output
|
|
270
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
271
|
+
logger.info("Computing metrics of %d case(s), writing to %s", len(cases), out_dir)
|
|
272
|
+
|
|
273
|
+
rows, failures = [], []
|
|
274
|
+
for case_name, case_rows, error in _run_all(_metrics_one, cases, config, "Computing metrics"):
|
|
275
|
+
if error:
|
|
276
|
+
failures.append({"case_id": case_name, "error": error})
|
|
277
|
+
else:
|
|
278
|
+
rows.extend(case_rows)
|
|
279
|
+
|
|
280
|
+
write_table(out_dir / METRICS_TABLE, rows)
|
|
281
|
+
write_table(out_dir / METRICS_FAILURES_TABLE, failures, columns=("case_id", "error"))
|
|
282
|
+
n_done = len(cases) - len(failures)
|
|
283
|
+
logger.info("Done: %d succeeded, %d failed", n_done, len(failures))
|
|
284
|
+
return n_done, len(failures)
|
mspdetect/cli.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Command line interface: ``mspdetect {check-config,run,metrics} config.yaml``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from pydantic import ValidationError
|
|
11
|
+
|
|
12
|
+
from mspdetect import __version__
|
|
13
|
+
from mspdetect.batch import run_detection, run_metrics
|
|
14
|
+
from mspdetect.config import BatchConfig
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _parser() -> argparse.ArgumentParser:
|
|
18
|
+
parser = argparse.ArgumentParser(
|
|
19
|
+
prog="mspdetect",
|
|
20
|
+
description="Mid-sagittal plane detection in head and neck CTs.",
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
23
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="show debug messages")
|
|
24
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
25
|
+
for name, help_text in [
|
|
26
|
+
("check-config", "validate a configuration file and print the full settings"),
|
|
27
|
+
("run", "detect the plane of all cases"),
|
|
28
|
+
("metrics", "compute metrics for all cases with a detected plane"),
|
|
29
|
+
]:
|
|
30
|
+
command = commands.add_parser(name, help=help_text)
|
|
31
|
+
command.add_argument("config", help="YAML configuration file")
|
|
32
|
+
return parser
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def main(argv: list[str] | None = None) -> int:
|
|
36
|
+
"""Entry point of the ``mspdetect`` command."""
|
|
37
|
+
args = _parser().parse_args(argv)
|
|
38
|
+
logging.basicConfig(
|
|
39
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
40
|
+
format="%(asctime)s %(levelname)s %(message)s",
|
|
41
|
+
datefmt="%H:%M:%S",
|
|
42
|
+
)
|
|
43
|
+
try:
|
|
44
|
+
config = BatchConfig.from_yaml(args.config)
|
|
45
|
+
except (OSError, ValidationError, yaml.YAMLError) as e:
|
|
46
|
+
print(f"Invalid configuration {args.config}:\n{e}", file=sys.stderr) # noqa: T201
|
|
47
|
+
return 2
|
|
48
|
+
|
|
49
|
+
if args.command == "check-config":
|
|
50
|
+
print(yaml.safe_dump(config.model_dump(mode="json"), sort_keys=False)) # noqa: T201
|
|
51
|
+
return 0
|
|
52
|
+
run = run_detection if args.command == "run" else run_metrics
|
|
53
|
+
_, n_failed = run(config)
|
|
54
|
+
return 1 if n_failed else 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
sys.exit(main())
|
mspdetect/config.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Configuration of MSP detection, metrics and batch processing.
|
|
2
|
+
|
|
3
|
+
All configurations are validated with `pydantic`_: unknown keys, wrong types and
|
|
4
|
+
inconsistent values raise an error instead of being silently ignored.
|
|
5
|
+
|
|
6
|
+
.. _pydantic: https://docs.pydantic.dev/latest/
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Literal
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
from pydantic import (
|
|
18
|
+
BaseModel,
|
|
19
|
+
ConfigDict,
|
|
20
|
+
Field,
|
|
21
|
+
NonNegativeInt,
|
|
22
|
+
PositiveInt,
|
|
23
|
+
field_validator,
|
|
24
|
+
model_validator,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _Config(BaseModel):
|
|
29
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CropConfig(_Config):
|
|
33
|
+
"""Which part of the CT is used to fit the plane.
|
|
34
|
+
|
|
35
|
+
Slices are counted along the patient's inferior→superior axis. Structures named
|
|
36
|
+
``Mandible`` and ``Body`` have a special meaning (see ``include_mandible`` and
|
|
37
|
+
``lateral_extent_mm``; ``Body`` is also used to mask out the couch).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
reference: str | None = Field(
|
|
41
|
+
default="GTVp",
|
|
42
|
+
description=(
|
|
43
|
+
"Structure around which the slab of slices is cropped. "
|
|
44
|
+
"None uses the whole scan (or `slice_range`)."
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
slice_range: tuple[NonNegativeInt, NonNegativeInt] | None = Field(
|
|
48
|
+
default=None,
|
|
49
|
+
description="Explicit [start, stop) slice range; overrides `reference`.",
|
|
50
|
+
)
|
|
51
|
+
mode: Literal["margins", "fixed_size"] = Field(
|
|
52
|
+
default="margins",
|
|
53
|
+
description=(
|
|
54
|
+
"`margins`: extent of the reference plus `margins` slices. "
|
|
55
|
+
"`fixed_size`: 2*`fixed_size` + 1 slices centred on the reference "
|
|
56
|
+
"(or the reference extent if it is larger)."
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
margins: NonNegativeInt | tuple[NonNegativeInt, NonNegativeInt] = Field(
|
|
60
|
+
default=10,
|
|
61
|
+
description="Slices added below/above the reference: one value or [inferior, superior].",
|
|
62
|
+
)
|
|
63
|
+
fixed_size: PositiveInt = Field(
|
|
64
|
+
default=20,
|
|
65
|
+
description="Half-size (slices) of the window used in `fixed_size` mode.",
|
|
66
|
+
)
|
|
67
|
+
include_mandible: bool = Field(
|
|
68
|
+
default=True,
|
|
69
|
+
description=(
|
|
70
|
+
"If the reference ends below or within 5 slices of the lower edge of the "
|
|
71
|
+
"Mandible, extend the slab to include those mandible slices."
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
lateral_extent_mm: float | None = Field(
|
|
75
|
+
default=None,
|
|
76
|
+
gt=0,
|
|
77
|
+
description=(
|
|
78
|
+
"If set, keep only this width (mm, left-right) centred on the Body "
|
|
79
|
+
"(or image centre) to exclude the shoulders."
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
pad: NonNegativeInt = Field(
|
|
83
|
+
default=10,
|
|
84
|
+
description="Extra voxels around each crop used only for interpolating mirrored points.",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
@model_validator(mode="after")
|
|
88
|
+
def _check_slice_range(self):
|
|
89
|
+
if self.slice_range is not None and self.slice_range[0] >= self.slice_range[1]:
|
|
90
|
+
raise ValueError(
|
|
91
|
+
f"slice_range must be [start, stop) with start < stop, got {self.slice_range}"
|
|
92
|
+
)
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class OptimizerConfig(_Config):
|
|
97
|
+
"""Grid search initialisation and optimisation of the plane."""
|
|
98
|
+
|
|
99
|
+
azimuth_deg: tuple[float, float] = Field(
|
|
100
|
+
default=(0.0, 30.0),
|
|
101
|
+
description="Grid search over the azimuthal angle: [centre, half-width] in degrees.",
|
|
102
|
+
)
|
|
103
|
+
polar_deg: tuple[float, float] = Field(
|
|
104
|
+
default=(90.0, 40.0),
|
|
105
|
+
description="Grid search over the polar angle: [centre, half-width] in degrees.",
|
|
106
|
+
)
|
|
107
|
+
grid_steps: PositiveInt = Field(
|
|
108
|
+
default=10,
|
|
109
|
+
description="Number of angles per axis; the grid has grid_steps**2 planes.",
|
|
110
|
+
)
|
|
111
|
+
n_starts: PositiveInt = Field(
|
|
112
|
+
default=1,
|
|
113
|
+
description=(
|
|
114
|
+
"Number of optimisations, started from the best grid points. 1 uses only "
|
|
115
|
+
"the grid point with the lowest initial value; >1 returns the start with "
|
|
116
|
+
"the lowest final quadratic loss and reports whether the first start agrees with it."
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
@model_validator(mode="after")
|
|
121
|
+
def _check_n_starts(self):
|
|
122
|
+
if self.n_starts > self.grid_steps**2:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
f"n_starts ({self.n_starts}) cannot exceed the number of grid points "
|
|
125
|
+
f"({self.grid_steps**2})",
|
|
126
|
+
)
|
|
127
|
+
if self.azimuth_deg[1] < 0 or self.polar_deg[1] < 0:
|
|
128
|
+
raise ValueError("Half-widths of the angle ranges must be >= 0")
|
|
129
|
+
return self
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class DetectionConfig(_Config):
|
|
133
|
+
"""Everything that determines the detected plane of one case."""
|
|
134
|
+
|
|
135
|
+
hu_range: tuple[float, float] = Field(
|
|
136
|
+
default=(300.0, 2800.0),
|
|
137
|
+
description="Voxels with HU in [low, high] are considered bone.",
|
|
138
|
+
)
|
|
139
|
+
crop: CropConfig = CropConfig()
|
|
140
|
+
optimizer: OptimizerConfig = OptimizerConfig()
|
|
141
|
+
|
|
142
|
+
@model_validator(mode="after")
|
|
143
|
+
def _check_hu_range(self):
|
|
144
|
+
if self.hu_range[0] >= self.hu_range[1]:
|
|
145
|
+
raise ValueError(f"hu_range must be [low, high] with low < high, got {self.hu_range}")
|
|
146
|
+
return self
|
|
147
|
+
|
|
148
|
+
def fingerprint(self) -> str:
|
|
149
|
+
"""Short hash identifying these settings (used to detect stale results)."""
|
|
150
|
+
dump = json.dumps(self.model_dump(mode="json"), sort_keys=True)
|
|
151
|
+
return hashlib.sha256(dump.encode()).hexdigest()[:16]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class DataConfig(_Config):
|
|
155
|
+
"""Where the cases are and how their files are named."""
|
|
156
|
+
|
|
157
|
+
root: Path = Field(description="Directory containing one sub-directory per case.")
|
|
158
|
+
patients: list[str] | Path | None = Field(
|
|
159
|
+
default=None,
|
|
160
|
+
description=(
|
|
161
|
+
"Cases to process: a list of sub-directory names (may contain '/'), a "
|
|
162
|
+
"text file with one name per line, or None for all sub-directories."
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
image: str | list[str] = Field(
|
|
166
|
+
default="CT",
|
|
167
|
+
description="File name(s) of the CT without extension (case-insensitive).",
|
|
168
|
+
)
|
|
169
|
+
structures: dict[str, str | list[str]] = Field(
|
|
170
|
+
default_factory=dict,
|
|
171
|
+
description="File name(s) of structures without extension (case-insensitive).",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
@field_validator("patients", mode="before")
|
|
175
|
+
@classmethod
|
|
176
|
+
def _ids_as_strings(cls, value):
|
|
177
|
+
# YAML reads numeric patient IDs as integers
|
|
178
|
+
if isinstance(value, list):
|
|
179
|
+
return [str(v) for v in value]
|
|
180
|
+
return value
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class MetricsConfig(_Config):
|
|
184
|
+
"""Which metrics to compute relative to the detected plane."""
|
|
185
|
+
|
|
186
|
+
output: Path | None = Field(
|
|
187
|
+
default=None,
|
|
188
|
+
description=(
|
|
189
|
+
"Directory for metrics.csv and metrics_failures.csv; None uses the "
|
|
190
|
+
"top-level `output`. The detected planes are always read from `output`."
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
structures: list[str] = Field(
|
|
194
|
+
default_factory=list,
|
|
195
|
+
description="Structures for which left/right volumes are computed.",
|
|
196
|
+
)
|
|
197
|
+
target: str | None = Field(
|
|
198
|
+
default="GTVp",
|
|
199
|
+
description="Structure for which laterality and distances to the plane are computed.",
|
|
200
|
+
)
|
|
201
|
+
plane: Literal["first_start", "best", "all_starts"] = Field(
|
|
202
|
+
default="first_start",
|
|
203
|
+
description=(
|
|
204
|
+
"Plane(s) used for the metrics: `first_start` = result of the optimisation "
|
|
205
|
+
"started from the grid point with the lowest initial value; `best` = lowest "
|
|
206
|
+
"final quadratic loss over all starts; `all_starts` = one row per start. "
|
|
207
|
+
"Identical if n_starts is 1."
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
volume_region: Literal["whole", "slab"] = Field(
|
|
211
|
+
default="whole",
|
|
212
|
+
description=(
|
|
213
|
+
"Region for left/right volumes: `whole` = the whole structures; `slab` = "
|
|
214
|
+
"only the slices used to detect the plane (full left-right and "
|
|
215
|
+
"anterior-posterior extent)."
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class BatchConfig(_Config):
|
|
221
|
+
"""Configuration file of the command line interface."""
|
|
222
|
+
|
|
223
|
+
data: DataConfig
|
|
224
|
+
output: Path = Field(description="Directory where results are written.")
|
|
225
|
+
detection: DetectionConfig = DetectionConfig()
|
|
226
|
+
metrics: MetricsConfig = MetricsConfig()
|
|
227
|
+
n_jobs: PositiveInt = Field(default=1, description="Number of cases processed in parallel.")
|
|
228
|
+
overwrite: bool = Field(default=False, description="Recompute cases that already have results.")
|
|
229
|
+
|
|
230
|
+
@classmethod
|
|
231
|
+
def from_yaml(cls, path: str | Path) -> BatchConfig:
|
|
232
|
+
"""Load a YAML file; relative paths are resolved relative to the file."""
|
|
233
|
+
path = Path(path)
|
|
234
|
+
with path.open() as f:
|
|
235
|
+
raw = yaml.safe_load(f) or {}
|
|
236
|
+
base = path.resolve().parent
|
|
237
|
+
|
|
238
|
+
def resolve(value):
|
|
239
|
+
return str(base / value) if isinstance(value, str) else value
|
|
240
|
+
|
|
241
|
+
data = raw.get("data")
|
|
242
|
+
if isinstance(data, dict):
|
|
243
|
+
data["root"] = resolve(data.get("root"))
|
|
244
|
+
if isinstance(data.get("patients"), str):
|
|
245
|
+
data["patients"] = resolve(data["patients"])
|
|
246
|
+
if "output" in raw:
|
|
247
|
+
raw["output"] = resolve(raw["output"])
|
|
248
|
+
metrics = raw.get("metrics")
|
|
249
|
+
if isinstance(metrics, dict) and metrics.get("output") is not None:
|
|
250
|
+
metrics["output"] = resolve(metrics["output"])
|
|
251
|
+
return cls.model_validate(raw)
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def metrics_output(self) -> Path:
|
|
255
|
+
"""Directory for the metrics tables: ``metrics.output`` or ``output``."""
|
|
256
|
+
return self.metrics.output or self.output
|