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]