"""Configuration loading and validation for autods_pet.
Reads an INI file with per-ROI parameters and statistics choices.
Any key not provided in the user file falls back to built-in defaults.
Uses only stdlib ``configparser`` - no external dependencies.
"""
from __future__ import annotations
import configparser
import copy
import dataclasses
import re
from pathlib import Path
from typing import Any
_DEFAULTS: dict[str, Any] = {
"paths": {
"basepath": "",
"patient_list": "",
"metadata_csv": "",
"output_dir": "",
"ct_nifti": "{patient_id}_results/images/CT.nii.gz",
"pet_nifti": "{patient_id}_results/images/PET.nii.gz",
"pet_suv": "{patient_id}_results/images/PET_SUV.nii.gz",
"pet_registered": "{patient_id}_results/images/PET_SUV_reg.nii.gz",
"seg_dir": "{patient_id}_results/segmentations",
"vert_body_seg": "{patient_id}_results/segmentations/vertebral_body.nii.gz",
"pet_metadata": "{patient_id}_results/metadata/PET_metadata.json",
"elastix_report": "{patient_id}_results/metadata/elastix_transform.txt",
"deauville_csv": "{patient_id}_results/DeauvilleScores/deauville_scores.csv",
"suv_csv": "{patient_id}_results/SUV/SUV_values.csv",
"input_seg_dir": "{patient_id}/segmentations",
},
"totalsegmentator": {
"license": "",
"fast": False,
},
"lumbar_vb": {
"labels": [29, 28, 27],
"erosion_mm": 3.0,
"stats": ["p95"],
},
"aorta_mbp": {
"vertebra_labels": [36, 37, 38, 39, 40],
"slab_axis": 2,
"heart_exclusion_mode": "dilate_intersection",
"heart_dilation_mm": 6.0,
"heart_distance_mm": 12.0,
"aorta_erosion_mm": 4.0,
"stats": ["median"],
},
"liver": {
"erosion_mm": 10.0,
"stats": ["median"],
},
"brain": {
"label": 90,
"grey_matter_only": True,
"cortical_thickness_mm": 5.0,
"stats": ["median"],
},
"long_bones": {
"bones": [
{"name": "femur_L", "erosion_mm": 5.0},
{"name": "femur_R", "erosion_mm": 5.0},
{"name": "humerus_L", "erosion_mm": 4.0},
{"name": "humerus_R", "erosion_mm": 4.0},
],
"diaphysis_keep_pct": 60,
"stats": ["p95"],
},
"deauville": {
"liver_multiplier": 2.0,
},
"targets": {}, # custom targets populated from [targets.*] sections
"output": {
"save_raw_masks": False,
"save_refined_masks": True,
"subtract_lesions_from_marrow": False,
},
"dicom": {
"size_threshold_kb": 100,
},
}
# Schema: maps (section, key) -> expected type for typed parsing
# Keys not listed here are kept as strings.
_TYPED_KEYS: dict[tuple[str, str], str] = {
("lumbar_vb", "labels"): "int_list",
("lumbar_vb", "erosion_mm"): "float",
("lumbar_vb", "stats"): "str_list",
("aorta_mbp", "vertebra_labels"): "int_list",
("aorta_mbp", "slab_axis"): "int",
("aorta_mbp", "heart_dilation_mm"): "float",
("aorta_mbp", "heart_distance_mm"): "float",
("aorta_mbp", "aorta_erosion_mm"): "float",
("aorta_mbp", "stats"): "str_list",
("liver", "erosion_mm"): "float",
("liver", "max_hole_volume_mm3"): "float_or_none",
("liver", "stats"): "str_list",
("brain", "label"): "int",
("brain", "grey_matter_only"): "bool",
("brain", "cortical_thickness_mm"): "float",
("brain", "stats"): "str_list",
("long_bones", "diaphysis_keep_pct"): "int",
("long_bones", "stats"): "str_list",
("focal_lesion", "stats"): "str_list",
("focal_lesion", "mask_filename"): "str_list",
("focal_lesion", "segment_label"): "str_list",
("paramedullary", "stats"): "str_list",
("paramedullary", "mask_filename"): "str_list",
("paramedullary", "segment_label"): "str_list",
("extramedullary", "stats"): "str_list",
("extramedullary", "mask_filename"): "str_list",
("extramedullary", "segment_label"): "str_list",
("deauville", "liver_multiplier"): "float",
("dicom", "size_threshold_kb"): "int",
("totalsegmentator", "fast"): "bool",
("output", "save_raw_masks"): "bool",
("output", "save_refined_masks"): "bool",
("output", "subtract_lesions_from_marrow"): "bool",
}
# Bone sub-section keys
_BONE_TYPED_KEYS: dict[str, str] = {
"erosion_mm": "float",
}
# Fixed labels from TotalSegmentator (not user-configurable)
BONE_LABELS: dict[str, int] = {
"femur_L": 75,
"femur_R": 76,
"humerus_L": 69,
"humerus_R": 70,
}
# Valid stat names (p<N> is validated separately via regex)
_FIXED_STATS = {"mean", "median", "min", "max"}
_PERCENTILE_RE = re.compile(r"^p(\d+(?:\.\d+)?)$")
_LONG_BONES_PREFIX = "long_bones."
_TARGETS_PREFIX = "targets."
# Named target ROI sections (opt-in: only processed when present in INI)
NAMED_TARGET_SECTIONS = ("focal_lesion", "paramedullary", "extramedullary")
# Typed keys for custom [targets.*] sub-sections
_TARGET_TYPED_KEYS: dict[str, str] = {
"stats": "str_list",
"mask_filename": "str_list",
"segment_label": "str_list",
}
# -- Profile definitions -------------------------------------------------
_PROFILE_DESCRIPTIONS: dict[str, str] = {
"quick": (
"Fast TotalSeg, no license needed, no target masks, no saved masks.\n"
"; For pipeline testing, QC, and rapid cohort screening."
),
"standard": (
"High-res TotalSeg, no license needed, no target masks, saves refined masks.\n"
"; Balanced starting point - produces LB_DS and reference SUV values."
),
"advanced": (
"High-res TotalSeg, license required (BM_DS enabled), no target masks,\n"
"; saves refined masks. Adds vertebral body segmentation for Bone Marrow DS."
),
"full": (
"High-res TotalSeg, license required, all target masks (FL/PM/EM),\n"
"; saves raw + refined masks. Complete analysis - all five DS + BLR."
),
"brain": (
"High-res TotalSeg, no license needed, brain + liver ROIs only.\n"
"; Produces Brain-to-Liver Ratio (BLR) for neurological assessment."
),
}
_PROFILE_OVERRIDES: dict[str, dict[str, Any]] = {
"quick": {
"totalsegmentator": {"fast": True},
"output": {"save_raw_masks": False, "save_refined_masks": False},
},
"standard": {},
"advanced": {
"totalsegmentator": {"license": "YOUR_LICENSE_KEY_HERE"},
},
"full": {
"totalsegmentator": {"license": "YOUR_LICENSE_KEY_HERE"},
"liver": {"max_hole_volume_mm3": 500.0},
"output": {"save_raw_masks": True, "save_refined_masks": True},
},
"brain": {},
}
# Sections to omit from the generated config for a given profile.
_PROFILE_SKIP_SECTIONS: dict[str, set[str]] = {
"brain": {"lumbar_vb", "aorta_mbp", "long_bones"},
}
# Default values for named target sections when enabled (full profile).
_NAMED_TARGET_DEFAULTS: dict[str, dict[str, str]] = {
"focal_lesion": {
"mask_filename": "focal_lesion",
"segment_label": "Focal lesion, FL, focal_lesion",
"stats": "max, p90",
},
"paramedullary": {
"mask_filename": "PM_lesion",
"segment_label": "Paramedullary, PM, PM_lesion",
"stats": "max, p90",
},
"extramedullary": {
"mask_filename": "EM_lesion",
"segment_label": "Extramedullary, EM, EM_lesion",
"stats": "max, p90",
},
}
# Profiles that enable named target sections (uncommented in generated INI).
_PROFILES_WITH_TARGETS: set[str] = {"full"}
PROFILE_NAMES: tuple[str, ...] = tuple(_PROFILE_DESCRIPTIONS)
def _merge_profile(profile: str) -> dict[str, Any]:
"""Return *_DEFAULTS* deep-merged with the overrides for *profile*."""
merged = copy.deepcopy(_DEFAULTS)
for section, values in _PROFILE_OVERRIDES.get(profile, {}).items():
if section in merged and isinstance(merged[section], dict):
merged[section].update(values)
else:
merged[section] = values
return merged
def _parse_str_list(raw: str) -> list[str]:
"""Parse a comma-separated string into a list of stripped strings."""
return [s.strip() for s in raw.split(",") if s.strip()]
def _parse_int_list(raw: str) -> list[int]:
"""Parse a comma-separated string into a list of ints."""
return [int(s.strip()) for s in raw.split(",") if s.strip()]
def _cast_value(raw: str, typ: str) -> Any:
"""Cast a raw INI string to the expected Python type."""
if typ == "bool":
return raw.strip().lower() in ("true", "1", "yes")
if typ == "int":
return int(raw)
if typ == "float":
return float(raw)
if typ == "float_or_none":
stripped = raw.strip().lower()
if stripped in ("none", ""):
return None
return float(raw)
if typ == "int_list":
return _parse_int_list(raw)
if typ == "str_list":
return _parse_str_list(raw)
return raw
[docs]
def default_config() -> dict[str, Any]:
"""Return a deep copy of the built-in default configuration."""
return copy.deepcopy(_DEFAULTS)
def _load_paths(ini: configparser.ConfigParser, cfg: dict[str, Any]) -> None:
"""Load the ``[paths]`` section (all strings, no type coercion)."""
if ini.has_section("paths"):
for key in ini.options("paths"):
cfg["paths"][key] = ini.get("paths", key)
def _load_totalsegmentator(ini: configparser.ConfigParser, cfg: dict[str, Any]) -> None:
"""Load the ``[totalsegmentator]`` section with typed values."""
if ini.has_section("totalsegmentator"):
for key in ini.options("totalsegmentator"):
raw = ini.get("totalsegmentator", key)
typ = _TYPED_KEYS.get(("totalsegmentator", key))
cfg["totalsegmentator"][key] = _cast_value(raw, typ) if typ else raw
def _load_sections(
ini: configparser.ConfigParser, cfg: dict[str, Any]
) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]]]:
"""Load ROI, bone-sub, and target-sub sections.
Returns ``(bone_sections, target_sections)`` for post-processing.
"""
bone_sections: dict[str, dict[str, Any]] = {}
target_sections: dict[str, dict[str, Any]] = {}
for section in ini.sections():
if section in ("paths", "totalsegmentator"):
continue
# Bone sub-sections: [long_bones.femur_L] etc.
if section.startswith(_LONG_BONES_PREFIX):
bone_name = section[len(_LONG_BONES_PREFIX) :]
bone: dict[str, Any] = {"name": bone_name}
for key in ini.options(section):
raw = ini.get(section, key)
typ = _BONE_TYPED_KEYS.get(key)
bone[key] = _cast_value(raw, typ) if typ else raw
bone_sections[bone_name] = bone
continue
# Custom target sub-sections: [targets.my_roi] etc.
if section.startswith(_TARGETS_PREFIX):
target_name = section[len(_TARGETS_PREFIX) :]
target: dict[str, Any] = {"name": target_name}
for key in ini.options(section):
raw = ini.get(section, key)
typ = _TARGET_TYPED_KEYS.get(key)
target[key] = _cast_value(raw, typ) if typ else raw
target_sections[target_name] = target
continue
# Named target sections (opt-in, not in _DEFAULTS)
if section in NAMED_TARGET_SECTIONS and section not in cfg:
cfg[section] = {}
# Regular ROI section
if section not in cfg:
raise ValueError(f"Unknown config section: {section!r}")
for key in ini.options(section):
raw = ini.get(section, key)
typ = _TYPED_KEYS.get((section, key))
cfg[section][key] = _cast_value(raw, typ) if typ else raw
return bone_sections, target_sections
def _inject_bone_labels(
cfg: dict[str, Any], bone_sections: dict[str, dict[str, Any]]
) -> None:
"""Reconstruct bones list from sub-sections and inject fixed labels."""
if bone_sections:
for bone_name, bone_dict in bone_sections.items():
if bone_name in BONE_LABELS:
bone_dict["label"] = BONE_LABELS[bone_name]
cfg["long_bones"]["bones"] = list(bone_sections.values())
else:
for bone_dict in cfg["long_bones"]["bones"]:
name = bone_dict["name"]
if name in BONE_LABELS:
bone_dict["label"] = BONE_LABELS[name]
[docs]
def load_config(
path: str | Path | None = None, *, validate: bool = True
) -> dict[str, Any]:
"""Load an INI configuration file and merge it with built-in defaults.
Parameters
----------
path : str, Path, or None
Path to an INI file. If *None*, returns the built-in defaults.
validate : bool
When *True* (default) the merged config is validated and a
``ValueError`` is raised on the first problem. Set to *False*
to skip validation - useful when you want to run
:class:`ConfigValidator` yourself to collect **all** issues.
Returns
-------
dict
Merged configuration (user values override defaults).
"""
cfg = default_config()
if path is None:
return cfg
path = Path(path)
if not path.exists():
raise FileNotFoundError(f"Config file not found: {path}")
ini = configparser.ConfigParser()
ini.read(path, encoding="utf-8")
_load_paths(ini, cfg)
_load_totalsegmentator(ini, cfg)
bone_sections, target_sections = _load_sections(ini, cfg)
if target_sections:
cfg["targets"] = target_sections
_inject_bone_labels(cfg, bone_sections)
if validate:
_validate(cfg)
return cfg
[docs]
def parse_stat(name: str) -> tuple[str, float | None]:
"""Parse a stat name into ``(kind, param)``.
Parameters
----------
name : str
Stat name such as ``"mean"``, ``"median"``, ``"p95"``.
Returns
-------
tuple[str, float | None]
``("percentile", 95.0)`` for ``"p95"``,
``("mean", None)`` for ``"mean"``, etc.
Raises
------
ValueError
If *name* is not a recognised statistic.
Examples
--------
>>> from autods_pet.config import parse_stat
>>> parse_stat("mean")
('mean', None)
>>> parse_stat("p95")
('percentile', 95.0)
>>> parse_stat("median")
('median', None)
"""
m = _PERCENTILE_RE.match(name)
if m:
return ("percentile", float(m.group(1)))
if name in _FIXED_STATS:
return (name, None)
raise ValueError(
f"Unknown stat {name!r}. "
f"Valid: {sorted(_FIXED_STATS)} or p<N> (e.g. p90, p95)."
)
[docs]
def get_roi_config(cfg: dict[str, Any], roi: str) -> dict[str, Any]:
"""Return the sub-dict for a single ROI, raising on unknown names."""
if roi not in cfg:
raise KeyError(f"No config section for ROI {roi!r}")
result: dict[str, Any] = cfg[roi]
return result
[docs]
def get_all_targets(cfg: dict[str, Any]) -> list[dict[str, Any]]:
"""Return a list of all configured target ROIs (named + custom).
Each entry is a dict with at least ``name``, ``mask_filename``, ``stats``.
Named targets (focal_lesion, paramedullary, extramedullary) are only
included if the user explicitly added the section to the INI file.
Custom targets from ``[targets.*]`` sections are always included.
"""
targets = []
for section_name in NAMED_TARGET_SECTIONS:
if section_name in cfg:
entry = cfg[section_name].copy()
entry["name"] = section_name
targets.append(entry)
for target_name, target_cfg in cfg.get("targets", {}).items():
entry = target_cfg.copy()
entry.setdefault("name", target_name)
targets.append(entry)
return targets
[docs]
@dataclasses.dataclass
class ValidationIssue:
"""A single validation problem found in a configuration."""
section: str
key: str | None
level: str # "error" or "warning"
message: str
[docs]
class ConfigValidator:
"""Collect all validation issues from a merged configuration dict.
Unlike :func:`load_config`, which raises on the first error, this class
accumulates every problem so that users can fix them all at once.
Parameters
----------
cfg : dict
A merged configuration dict (as returned by :func:`load_config`
with ``validate=False``).
Examples
--------
>>> from autods_pet.config import load_config, ConfigValidator
>>> cfg = load_config("config.ini", validate=False)
>>> v = ConfigValidator(cfg)
>>> v.validate()
>>> if not v.is_valid:
... for issue in v.errors:
... print(issue)
"""
def __init__(self, cfg: dict[str, Any]) -> None:
self._cfg = cfg
self.issues: list[ValidationIssue] = []
# -- helpers ----------------------------------------------------------
def _add(
self,
section: str,
key: str | None,
level: str,
message: str,
) -> None:
self.issues.append(ValidationIssue(section, key, level, message))
# -- public API -------------------------------------------------------
@property
def errors(self) -> list[ValidationIssue]:
"""Return only error-level issues."""
return [i for i in self.issues if i.level == "error"]
@property
def warnings(self) -> list[ValidationIssue]:
"""Return only warning-level issues."""
return [i for i in self.issues if i.level == "warning"]
@property
def is_valid(self) -> bool:
"""Return *True* when no errors were found."""
return not self.errors
[docs]
def validate(self) -> list[ValidationIssue]:
"""Run all checks and return the list of issues found.
The same list is also available as :attr:`issues`.
"""
self.issues.clear()
self._check_roi_sections()
self._check_custom_targets()
return self.issues
# -- internal checks --------------------------------------------------
def _check_stats(self, section: str, values: dict[str, Any]) -> bool:
"""Validate stats presence and names. Returns True if stats are valid."""
stats = values.get("stats")
if stats is None:
self._add(
section, "stats", "error", f"Section {section!r} is missing 'stats'"
)
return False
if not isinstance(stats, list) or len(stats) == 0:
self._add(
section,
"stats",
"error",
f"Section {section!r}: 'stats' must be a non-empty list",
)
return False
for s in stats:
try:
parse_stat(s)
except ValueError as exc:
self._add(section, "stats", "error", str(exc))
return True
def _check_reference_roi(self, section: str, values: dict[str, Any]) -> None:
"""Validate section-specific constraints for reference ROIs."""
if section == "lumbar_vb":
self._check_non_negative(values, "erosion_mm", section)
elif section == "aorta_mbp":
self._check_non_negative(values, "aorta_erosion_mm", section)
if values.get("heart_exclusion_mode") not in (
"dilate_intersection",
"distance",
):
self._add(
section,
"heart_exclusion_mode",
"error",
"aorta_mbp.heart_exclusion_mode must be "
"'dilate_intersection' or 'distance'",
)
elif section == "liver":
self._check_non_negative(values, "erosion_mm", section)
elif section == "brain":
self._check_non_negative(values, "cortical_thickness_mm", section)
elif section == "long_bones":
pct = values.get("diaphysis_keep_pct", 60)
if not (1 <= pct <= 100):
self._add(
section,
"diaphysis_keep_pct",
"error",
f"long_bones.diaphysis_keep_pct must be 1..100, got {pct}",
)
elif section in NAMED_TARGET_SECTIONS:
self._check_target_identity(section, values)
def _check_roi_sections(self) -> None:
cfg = self._cfg
roi_sections = [
k
for k in cfg
if k
not in (
"paths",
"targets",
"dicom",
"totalsegmentator",
"output",
"deauville",
)
]
for section in roi_sections:
values = cfg[section]
if not self._check_stats(section, values):
continue
self._check_reference_roi(section, values)
def _check_target_identity(self, section: str, values: dict[str, Any]) -> None:
"""Validate that a target section has at least one identity key set.
A section is valid when it sets ``mask_filename`` (NIfTI/NRRD
discovery) and/or ``segment_label`` (DICOM SEG discovery). Empty
lists do not count.
"""
mf = values.get("mask_filename")
sl = values.get("segment_label")
mf_set = isinstance(mf, list) and len(mf) > 0
sl_set = isinstance(sl, list) and len(sl) > 0
if not (mf_set or sl_set):
self._add(
section,
"mask_filename",
"error",
f"Section {section!r} must set at least one of "
"'mask_filename' (NIfTI/NRRD) or 'segment_label' (DICOM SEG)",
)
def _check_custom_targets(self) -> None:
for target_name, target_cfg in self._cfg.get("targets", {}).items():
self._check_target_identity(f"targets.{target_name}", target_cfg)
stats = target_cfg.get("stats")
if stats is None:
self._add(
f"targets.{target_name}",
"stats",
"error",
f"Custom target {target_name!r} is missing 'stats'",
)
elif not isinstance(stats, list) or len(stats) == 0:
self._add(
f"targets.{target_name}",
"stats",
"error",
f"Custom target {target_name!r}: 'stats' must be a non-empty list",
)
else:
for s in stats:
try:
parse_stat(s)
except ValueError as exc:
self._add(f"targets.{target_name}", "stats", "error", str(exc))
def _check_non_negative(self, d: dict[str, Any], key: str, section: str) -> None:
val = d.get(key)
if val is not None and val < 0:
self._add(section, key, "error", f"{section}.{key} must be >= 0, got {val}")
def _validate(cfg: dict[str, Any]) -> None:
"""Validate the merged config, raising ValueError on the first problem."""
validator = ConfigValidator(cfg)
validator.validate()
if validator.errors:
raise ValueError(validator.errors[0].message)
def _fmt_value(value: object) -> str:
"""Format a config value for INI output."""
if isinstance(value, bool):
return str(value).lower()
if isinstance(value, list):
return ", ".join(str(v) for v in value)
return str(value)
def _write_paths_section(lines: list[str], cfg: dict[str, Any]) -> None:
lines.append("; Base directory that contains one sub-folder per patient.")
lines.append("[paths]")
for key, val in cfg["paths"].items():
if key == "input_seg_dir":
lines.append("; Input segmentations folder (relative to basepath).")
lines.append(
"; Pre-existing TotalSeg and refined masks here are copied to output."
)
lines.append(f"{key} = {val}")
lines.append("")
def _write_totalsegmentator_section(lines: list[str], cfg: dict[str, Any]) -> None:
lines.append("; TotalSegmentator settings (license key and fast mode).")
lines.append("[totalsegmentator]")
ts = cfg["totalsegmentator"]
if ts.get("license"):
lines.append(
"; Enter your TotalSegmentator license key to enable vertebral body segmentation."
)
else:
lines.append(
"; No license - vertebral body segmentation will be skipped (BM_DS unavailable)."
)
for key, val in ts.items():
lines.append(f"{key} = {_fmt_value(val)}")
lines.append("")
def _write_roi_sections(lines: list[str], cfg: dict[str, Any], skip: set[str]) -> None:
if "lumbar_vb" not in skip:
lines.append("; Lumbar vertebral body ROI.")
lines.append("[lumbar_vb]")
for key, val in cfg["lumbar_vb"].items():
lines.append(f"{key} = {_fmt_value(val)}")
lines.append("")
if "aorta_mbp" not in skip:
lines.append("; Aorta medial blood pool ROI.")
lines.append("[aorta_mbp]")
for key, val in cfg["aorta_mbp"].items():
lines.append(f"{key} = {_fmt_value(val)}")
lines.append("")
lines.append("; Liver ROI.")
lines.append("[liver]")
for key, val in cfg["liver"].items():
lines.append(f"{key} = {_fmt_value(val)}")
lines.append("")
lines.append("; Brain ROI (for Brain-to-Liver Ratio).")
lines.append("; When grey_matter_only = true, extracts cortical shell")
lines.append("; (original minus eroded by cortical_thickness_mm).")
lines.append("[brain]")
for key, val in cfg["brain"].items():
lines.append(f"{key} = {_fmt_value(val)}")
lines.append("")
if "long_bones" not in skip:
lines.append("; Long bones ROI (diaphysis extraction).")
lines.append("[long_bones]")
lb = cfg["long_bones"]
lines.append(f"diaphysis_keep_pct = {lb['diaphysis_keep_pct']}")
lines.append(f"stats = {_fmt_value(lb['stats'])}")
lines.append("")
for bone in lb["bones"]:
lines.append(f"[long_bones.{bone['name']}]")
lines.append(f"erosion_mm = {bone['erosion_mm']}")
lines.append("")
_TARGET_DOC = [
"; Target ROI sections - manual lesion masks.",
";",
"; Mask discovery is recursive: place files anywhere under the patient",
"; input directory. Both NIfTI/NRRD and DICOM SEG are supported.",
";",
"; - mask_filename : stem(s) for .nii.gz / .nii / .nrrd files. A",
'; single stem (e.g. "focal_lesion") OR a comma',
'; list (e.g. "focal_lesion, FL_mask, GTV").',
"; Searched recursively under input_dir. This is",
"; the only key NIfTI/NRRD users need.",
"; - segment_label : SegmentLabel(s) inside a DICOM SEG (single",
"; value or comma list, case-insensitive). The",
"; SEG file is identified by matching its",
"; ReferencedSeriesSequence to the PET",
"; SeriesInstanceUID - no filename or location",
"; required. Only consulted for .dcm files.",
";",
"; Set either, both, or neither. DICOM SEG wins when both formats",
"; match the same target for the same patient. Geometry is",
"; auto-detected and PET-space masks are auto-registered to CT.",
]
def _write_target_sections(lines: list[str], targets_enabled: bool) -> None:
lines.extend(_TARGET_DOC)
if targets_enabled:
for name, defaults in _NAMED_TARGET_DEFAULTS.items():
lines.append(f"[{name}]")
for key, val in defaults.items():
lines.append(f"{key} = {val}")
lines.append("")
else:
lines.append("; (sections below are commented out - uncomment to enable)")
for name, defaults in _NAMED_TARGET_DEFAULTS.items():
lines.append(";")
lines.append(f"; [{name}]")
for key, val in defaults.items():
lines.append(f"; {key} = {val}")
lines.append("")
lines.append("; Custom target ROIs: add [targets.<name>] sections.")
lines.append("; [targets.my_custom_roi]")
lines.append("; mask_filename = my_roi")
lines.append("; segment_label = my tumor label")
lines.append("; stats = max, median")
lines.append("")
def _write_output_and_dicom_sections(lines: list[str], cfg: dict[str, Any]) -> None:
lines.append("; Mask saving and extraction options.")
lines.append("[output]")
for key, val in cfg["output"].items():
if key == "subtract_lesions_from_marrow":
lines.append(
"; Subtract target lesion masks (FL, PM, EM, custom) from marrow"
)
lines.append("; ROIs (Lumbar VB, Long bones) before computing statistics.")
lines.append(f"{key} = {_fmt_value(val)}")
lines.append("")
lines.append("; DICOM conversion settings.")
lines.append("[dicom]")
for key, val in cfg["dicom"].items():
lines.append(f"{key} = {_fmt_value(val)}")
lines.append("")
[docs]
def create_default_config(path: str | Path, profile: str = "standard") -> Path:
"""Write a commented INI template for the given *profile*.
The generated file can be loaded by :func:`load_config` without errors
and serves as a starting point for users to customise.
Parameters
----------
path : str or Path
Destination file path.
profile : str
Profile name (one of :data:`PROFILE_NAMES`).
Returns
-------
Path
The path that was written.
"""
if profile not in _PROFILE_OVERRIDES:
raise ValueError(
f"Unknown profile {profile!r}. Choose from: {', '.join(PROFILE_NAMES)}"
)
path = Path(path)
cfg = _merge_profile(profile)
skip = _PROFILE_SKIP_SECTIONS.get(profile, set())
targets_enabled = profile in _PROFILES_WITH_TARGETS
lines: list[str] = []
# Header
lines.append(f"; autods-pet configuration - Profile: {profile}")
lines.append(f"; {_PROFILE_DESCRIPTIONS[profile]}")
lines.append(";")
lines.append(
f"; Edit as needed, then validate with: autods-pet validate-config {path.name}\n"
)
_write_paths_section(lines, cfg)
_write_totalsegmentator_section(lines, cfg)
_write_roi_sections(lines, cfg, skip)
_write_target_sections(lines, targets_enabled)
_write_output_and_dicom_sections(lines, cfg)
path.write_text("\n".join(lines), encoding="utf-8")
return path
[docs]
def resolve_output_dir(cfg: dict[str, Any]) -> Path:
"""Return the resolved output directory from *cfg*.
Uses ``cfg["paths"]["output_dir"]`` when set (absolute or relative to
the current working directory), otherwise falls back to ``CWD / "results"``.
"""
raw = cfg.get("paths", {}).get("output_dir", "")
if raw:
p = Path(raw)
return p if p.is_absolute() else Path.cwd() / p
return Path.cwd() / "results"