File size: 5,141 Bytes
d5d23f9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 | """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]
|