"""Public data types for model-independent contour post-processing.""" from __future__ import annotations from dataclasses import dataclass from typing import Optional, Tuple, Union import numpy as np PixelSpacing = Tuple[float, float] @dataclass(frozen=True) class AdaptiveContourConfig: """Configuration for sparse, editable closed contours. Distances are expressed in millimetres. ``pixel_spacing`` supplied to the public API is ordered as ``(row_mm, column_mm)`` while all returned points are ordered as ``[x, y]`` in input-image pixel coordinates. """ min_control_points: int = 8 max_control_points: int = 10 tolerance_mm: float = 1.5 min_spacing_mm: float = 4.0 tension: float = 0.95 samples_per_segment: int = 20 name: str = "adaptive" def validate(self) -> None: if self.min_control_points < 3: raise ValueError("min_control_points must be at least 3") if self.max_control_points < self.min_control_points: raise ValueError("max_control_points must be >= min_control_points") if self.tolerance_mm < 0 or self.min_spacing_mm < 0: raise ValueError("contour distances must be non-negative") if self.tension < 0: raise ValueError("tension must be non-negative") if self.samples_per_segment < 1: raise ValueError("samples_per_segment must be positive") @dataclass(frozen=True) class SplineContourConfig: """Configuration for historical B-spline smoothing and simplification. The dense mask boundary is first smoothed to ``n_points`` samples. Those samples are reduced to the first requested control-point count whose zero-smoothing reconstruction reaches ``control_point_iou_threshold``; otherwise the best candidate is retained. """ smoothing: float = 40.0 n_points: int = 100 control_point_counts: Tuple[int, ...] = (6, 8, 10) control_point_iou_threshold: float = 0.985 reconstruction_smoothing: float = 0.0 simplification_method: str = "curvature" name: str = "periodic_bspline" def validate(self) -> None: if self.smoothing < 0: raise ValueError("smoothing must be non-negative") if self.n_points < 4: raise ValueError("n_points must be at least 4") if not self.control_point_counts: raise ValueError("control_point_counts must not be empty") if any(count < 3 for count in self.control_point_counts): raise ValueError("all control-point counts must be at least 3") if tuple(sorted(set(self.control_point_counts))) != self.control_point_counts: raise ValueError("control_point_counts must be unique and increasing") if not 0.0 <= self.control_point_iou_threshold <= 1.0: raise ValueError("control_point_iou_threshold must be between 0 and 1") if self.reconstruction_smoothing < 0: raise ValueError("reconstruction_smoothing must be non-negative") if self.simplification_method != "curvature": raise ValueError("only curvature simplification is supported") ContourConfig = Union[AdaptiveContourConfig, SplineContourConfig] @dataclass(frozen=True) class ContourQualityMetrics: """Agreement between the dense mask boundary and rendered smooth curve.""" control_point_count: int boundary_mean_mm: float boundary_p95_mm: float boundary_max_mm: float mask_iou: float source_area_px: int rendered_area_px: int area_change_pct: float roughness_ratio: float def as_dict(self) -> dict[str, float | int]: return { "control_point_count": self.control_point_count, "boundary_mean_mm": self.boundary_mean_mm, "boundary_p95_mm": self.boundary_p95_mm, "boundary_max_mm": self.boundary_max_mm, "mask_iou": self.mask_iou, "source_area_px": self.source_area_px, "rendered_area_px": self.rendered_area_px, "area_change_pct": self.area_change_pct, "roughness_ratio": self.roughness_ratio, } @dataclass(frozen=True) class MaskToContourResult: """Dense, sparse, and rendered representations of one mask boundary.""" dense_contour: np.ndarray control_points: np.ndarray smooth_contour: np.ndarray rendered_mask: np.ndarray metrics: ContourQualityMetrics pixel_spacing: PixelSpacing preset_name: str @dataclass(frozen=True) class MyocardiumContourResult: """Paired inner/endocardial and outer/epicardial ring boundaries.""" endocardium: MaskToContourResult epicardium: MaskToContourResult rendered_myocardium_mask: np.ndarray mask_iou: float area_change_pct: float def validate_pixel_spacing(pixel_spacing: Optional[PixelSpacing]) -> PixelSpacing: spacing = (1.0, 1.0) if pixel_spacing is None else tuple(float(v) for v in pixel_spacing) if len(spacing) != 2 or not all(np.isfinite(v) and v > 0 for v in spacing): raise ValueError("pixel_spacing must contain two positive finite values") return spacing[0], spacing[1]