File size: 9,643 Bytes
21ccfaf
 
 
 
8b7a837
b379987
 
 
 
21ccfaf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8b7a837
b379987
 
 
 
8b7a837
21ccfaf
 
 
b379987
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21ccfaf
 
 
b379987
21ccfaf
 
 
b379987
 
21ccfaf
 
b379987
 
 
 
 
21ccfaf
 
b379987
 
 
 
 
 
 
 
 
 
21ccfaf
 
 
 
 
 
 
 
 
b379987
21ccfaf
 
b379987
8b7a837
 
 
 
 
 
 
 
b379987
 
8b7a837
 
b379987
8b7a837
 
b379987
8b7a837
b379987
8b7a837
 
b379987
8b7a837
b379987
8b7a837
b379987
8b7a837
 
b379987
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8b7a837
b379987
8b7a837
 
 
21ccfaf
 
 
b379987
21ccfaf
 
 
b379987
 
 
 
 
8b7a837
21ccfaf
 
 
b379987
21ccfaf
 
 
 
 
 
b379987
21ccfaf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8b7a837
 
 
21ccfaf
 
 
 
b379987
 
 
 
 
21ccfaf
b379987
 
21ccfaf
 
b379987
 
 
8b7a837
b379987
8b7a837
b379987
8b7a837
 
 
b379987
 
 
8b7a837
 
21ccfaf
 
 
 
 
 
 
8b7a837
 
b379987
 
21ccfaf
8b7a837
 
 
21ccfaf
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
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
"""Fitzpatrick skin type estimation via ITA (Individual Typology Angle).

Calibrated on 61 DFU images with expert ground truth.
Validation: 86.9% exact match, 98.4% adjacent, r=0.975.

Includes:
  - Shadow/wound contamination filtering via L* outlier rejection
  - Adaptive trimming (tighter when ITA variance is high)
  - Lighting quality assessment to flag poor illumination
"""
import numpy as np
import cv2
from dataclasses import dataclass
from typing import Optional

# Calibrated ITA thresholds for DFU clinical photography (61-image validation)
ITA_THRESHOLDS = {
    "I":   (46.86, float("inf")),
    "II":  (34.25, 46.86),
    "III": (20.87, 34.25),
    "IV":  (3.57, 20.87),
    "V":   (-28.38, 3.57),
    "VI":  (float("-inf"), -28.38),
}

FITZPATRICK_LABELS = {
    "I": "Very Light", "II": "Light", "III": "Intermediate",
    "IV": "Tan", "V": "Brown", "VI": "Dark",
}

# Lighting quality thresholds (L* scale 0-100)
L_SCENE_MIN = 35.0
L_SCENE_LOW = 50.0
L_SKIN_MIN = 25.0
L_SKIN_SUSPICIOUS = 40.0


@dataclass
class FitzpatrickResult:
    fitzpatrick_type: str
    fitzpatrick_int: int
    fitzpatrick_label: str
    ita_angle: float
    ita_std: float
    l_skin_mean: float
    b_skin_mean: float
    healthy_pixels: int
    healthy_ratio: float
    confidence: float
    l_scene_mean: float = 0.0
    lighting_quality: str = "good"
    lighting_warning: str = ""


def filter_shadow_pixels(l_values: np.ndarray, b_values: np.ndarray) -> tuple:
    """Remove shadow/contamination pixels from healthy skin sample.

    Strategy: In a well-sampled skin region, L* follows a unimodal distribution.
    Shadow contamination creates a low-L* tail. We detect this by checking if the
    distribution is bimodal or has excessive spread, and keep only the main mode.

    Returns filtered (l_values, b_values).
    """
    if len(l_values) < 100:
        return l_values, b_values

    # Compute L* statistics
    l_median = np.median(l_values)
    l_std = np.std(l_values)

    # If L* spread is reasonable (std < 12), no filtering needed
    if l_std < 12:
        return l_values, b_values

    # High spread: likely shadow contamination. Use IQR-based filtering.
    q1 = np.percentile(l_values, 25)
    q3 = np.percentile(l_values, 75)
    iqr = q3 - q1

    # Keep pixels within [Q1 - 0.5*IQR, Q3 + 1.5*IQR]
    # Asymmetric: more aggressive on the low end (shadows) than high end (specular)
    lo = q1 - 0.5 * iqr
    hi = q3 + 1.5 * iqr
    keep = (l_values >= lo) & (l_values <= hi)

    if np.sum(keep) < 50:
        # Fallback: just use upper half of L* values
        keep = l_values >= l_median

    return l_values[keep], b_values[keep]


def compute_ita(l_values: np.ndarray, b_values: np.ndarray) -> tuple:
    """Compute ITA angle from L* and b* values with adaptive trimming.

    ITA = arctan((L* - 50) / b*) * (180 / pi)
    Higher ITA = lighter skin, lower ITA = darker skin.

    Uses adaptive percentile trimming: starts at 10-90, tightens if variance is high.
    """
    ita_per_pixel = np.degrees(np.arctan2(l_values - 50.0, b_values))

    # First pass: 10th-90th percentile (tighter than original 5-95)
    p10, p90 = np.percentile(ita_per_pixel, [10, 90])
    trimmed = ita_per_pixel[(ita_per_pixel >= p10) & (ita_per_pixel <= p90)]

    if len(trimmed) < 10:
        trimmed = ita_per_pixel

    first_std = float(np.std(trimmed))

    # If still high variance, tighten to 25-75 (IQR)
    if first_std > 20 and len(ita_per_pixel) > 200:
        p25, p75 = np.percentile(ita_per_pixel, [25, 75])
        iqr_trimmed = ita_per_pixel[(ita_per_pixel >= p25) & (ita_per_pixel <= p75)]
        if len(iqr_trimmed) >= 50:
            trimmed = iqr_trimmed

    return float(np.mean(trimmed)), float(np.std(trimmed))


def classify_fitzpatrick(ita: float) -> tuple:
    """Classify ITA angle into Fitzpatrick type using calibrated DFU thresholds."""
    for ftype, (lo, hi) in ITA_THRESHOLDS.items():
        if lo <= ita < hi:
            idx = list(ITA_THRESHOLDS.keys()).index(ftype) + 1
            return ftype, idx
    return "III", 3


def assess_lighting(img_bgr: np.ndarray, l_skin_mean: float, ita_std: float) -> tuple:
    """Assess scene lighting quality for reliable Fitzpatrick estimation.

    Returns:
        (l_scene_mean, quality, warning, confidence_penalty)
    """
    lab_full = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2Lab).astype(np.float32)
    l_scene = float(np.mean(lab_full[:, :, 0]) * (100.0 / 255.0))

    warnings = []

    if l_scene < L_SCENE_MIN or l_skin_mean < L_SKIN_MIN:
        quality = "insufficient"
        warnings.append(
            f"Iluminacion insuficiente (L* escena={l_scene:.0f}, L* piel={l_skin_mean:.0f}). "
            "El tipo Fitzpatrick puede estar sobreestimado (piel aparenta mas oscura). "
            "Recapture con mejor iluminacion."
        )
        penalty = 0.15
    elif l_scene < L_SCENE_LOW or l_skin_mean < L_SKIN_SUSPICIOUS:
        quality = "low"
        warnings.append(
            f"Iluminacion suboptima (L* escena={l_scene:.0f}, L* piel={l_skin_mean:.0f}). "
            "El tipo Fitzpatrick podria estar 1-2 niveles sobreestimado."
        )
        penalty = 0.4
    else:
        quality = "good"
        penalty = 1.0

    # High ITA std indicates contaminated sample (shadows, wound edges)
    if ita_std > 20:
        if quality == "good":
            quality = "low"
        warnings.append(
            f"Alta variabilidad en la muestra de piel (ITA std={ita_std:.1f}). "
            "Posible contaminacion por sombras o bordes de herida."
        )
        penalty *= 0.5
    elif ita_std > 15:
        warnings.append(
            f"Variabilidad moderada (ITA std={ita_std:.1f}). "
            "Resultado puede tener +/- 1 nivel de error."
        )
        penalty *= 0.7

    warning = " ".join(warnings)
    return l_scene, quality, warning, penalty


def estimate_fitzpatrick(
    img_bgr: np.ndarray,
    masks: dict,
    periulcer_dilation_px: int = 60,
) -> FitzpatrickResult:
    """Estimate Fitzpatrick type from a DFU image using segmentation masks.

    Strategy:
        1. Healthy skin = foot region - dilated(perilesion + ulcer)
        2. Filter shadow/contamination pixels via L* outlier rejection
        3. Compute ITA with adaptive trimming
        4. Assess lighting quality and adjust confidence

    Args:
        img_bgr: BGR image (H, W, 3)
        masks: dict with keys 'foot', 'perilesion', 'ulcer' (bool arrays H, W)
        periulcer_dilation_px: Extra dilation around wound for safety margin (default 60)
    """
    h, w = img_bgr.shape[:2]
    foot = masks.get("foot", np.ones((h, w), dtype=bool))
    peri = masks.get("perilesion", np.zeros((h, w), dtype=bool))
    ulcer = masks.get("ulcer", np.zeros((h, w), dtype=bool))

    # Dilate ulcer+perilesion for safety margin (increased from 40 to 60)
    exclusion = (peri | ulcer).astype(np.uint8)
    if periulcer_dilation_px > 0:
        kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (periulcer_dilation_px, periulcer_dilation_px))
        exclusion = cv2.dilate(exclusion, kernel)
    exclusion = exclusion.astype(bool)

    # Healthy skin = foot minus exclusion zone
    healthy = foot & ~exclusion
    healthy_pixels = int(np.sum(healthy))

    if healthy_pixels < 100:
        healthy = foot & ~ulcer
        healthy_pixels = int(np.sum(healthy))

    if healthy_pixels < 50:
        return FitzpatrickResult(
            fitzpatrick_type="III", fitzpatrick_int=3,
            fitzpatrick_label="Intermediate",
            ita_angle=0.0, ita_std=0.0,
            l_skin_mean=0.0, b_skin_mean=0.0,
            healthy_pixels=healthy_pixels,
            healthy_ratio=healthy_pixels / (h * w),
            confidence=0.0,
            l_scene_mean=0.0,
            lighting_quality="insufficient",
            lighting_warning="Insuficientes pixeles de piel sana para estimar Fitzpatrick.",
        )

    # Convert to L*a*b*
    lab = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2Lab).astype(np.float32)
    l_values = lab[healthy, 0] * (100.0 / 255.0)
    b_values = lab[healthy, 2] - 128.0

    # Filter shadow/contamination pixels BEFORE computing ITA
    l_filtered, b_filtered = filter_shadow_pixels(l_values, b_values)

    # Compute ITA with adaptive trimming
    ita_mean, ita_std = compute_ita(l_filtered, b_filtered)
    ftype, fint = classify_fitzpatrick(ita_mean)

    l_skin_mean = float(np.mean(l_filtered))
    b_skin_mean = float(np.mean(b_filtered))
    filtered_pixels = len(l_filtered)

    # Lighting quality assessment (now also considers ITA std)
    l_scene, lighting_quality, lighting_warning, lighting_penalty = assess_lighting(
        img_bgr, l_skin_mean, ita_std
    )

    # Confidence: pixel count + ITA consistency + coverage + lighting
    pixel_conf = min(filtered_pixels / 5000.0, 1.0)
    ita_conf = max(0.0, 1.0 - (ita_std / 25.0))
    coverage_conf = min((filtered_pixels / (h * w)) / 0.15, 1.0)
    base_confidence = pixel_conf * 0.3 + ita_conf * 0.4 + coverage_conf * 0.3
    confidence = base_confidence * lighting_penalty

    return FitzpatrickResult(
        fitzpatrick_type=ftype,
        fitzpatrick_int=fint,
        fitzpatrick_label=FITZPATRICK_LABELS[ftype],
        ita_angle=round(ita_mean, 2),
        ita_std=round(ita_std, 2),
        l_skin_mean=round(l_skin_mean, 2),
        b_skin_mean=round(b_skin_mean, 2),
        healthy_pixels=filtered_pixels,
        healthy_ratio=round(filtered_pixels / (h * w), 4),
        confidence=round(confidence, 3),
        l_scene_mean=round(l_scene, 2),
        lighting_quality=lighting_quality,
        lighting_warning=lighting_warning,
    )