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