fix: shadow filtering + adaptive ITA trimming + increased periulcer dilation (60px) — reduces Fitzpatrick overestimation
Browse files- src/fitzpatrick_estimator.py +130 -60
src/fitzpatrick_estimator.py
CHANGED
|
@@ -3,8 +3,10 @@
|
|
| 3 |
Calibrated on 61 DFU images with expert ground truth.
|
| 4 |
Validation: 86.9% exact match, 98.4% adjacent, r=0.975.
|
| 5 |
|
| 6 |
-
Includes
|
| 7 |
-
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
import numpy as np
|
| 10 |
import cv2
|
|
@@ -27,42 +29,93 @@ FITZPATRICK_LABELS = {
|
|
| 27 |
}
|
| 28 |
|
| 29 |
# Lighting quality thresholds (L* scale 0-100)
|
| 30 |
-
L_SCENE_MIN = 35.0
|
| 31 |
-
L_SCENE_LOW = 50.0
|
| 32 |
-
L_SKIN_MIN = 25.0
|
| 33 |
-
L_SKIN_SUSPICIOUS = 40.0
|
| 34 |
|
| 35 |
|
| 36 |
@dataclass
|
| 37 |
class FitzpatrickResult:
|
| 38 |
-
fitzpatrick_type: str
|
| 39 |
-
fitzpatrick_int: int
|
| 40 |
-
fitzpatrick_label: str
|
| 41 |
-
ita_angle: float
|
| 42 |
-
ita_std: float
|
| 43 |
-
l_skin_mean: float
|
| 44 |
-
b_skin_mean: float
|
| 45 |
-
healthy_pixels: int
|
| 46 |
-
healthy_ratio: float
|
| 47 |
-
confidence: float
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
|
| 54 |
def compute_ita(l_values: np.ndarray, b_values: np.ndarray) -> tuple:
|
| 55 |
-
"""Compute ITA angle from L* and b* values with
|
| 56 |
|
| 57 |
ITA = arctan((L* - 50) / b*) * (180 / pi)
|
| 58 |
Higher ITA = lighter skin, lower ITA = darker skin.
|
|
|
|
|
|
|
| 59 |
"""
|
| 60 |
ita_per_pixel = np.degrees(np.arctan2(l_values - 50.0, b_values))
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
| 64 |
if len(trimmed) < 10:
|
| 65 |
trimmed = ita_per_pixel
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
return float(np.mean(trimmed)), float(np.std(trimmed))
|
| 67 |
|
| 68 |
|
|
@@ -72,70 +125,83 @@ def classify_fitzpatrick(ita: float) -> tuple:
|
|
| 72 |
if lo <= ita < hi:
|
| 73 |
idx = list(ITA_THRESHOLDS.keys()).index(ftype) + 1
|
| 74 |
return ftype, idx
|
| 75 |
-
return "III", 3
|
| 76 |
|
| 77 |
|
| 78 |
-
def assess_lighting(img_bgr: np.ndarray, l_skin_mean: float) -> tuple:
|
| 79 |
"""Assess scene lighting quality for reliable Fitzpatrick estimation.
|
| 80 |
|
| 81 |
Returns:
|
| 82 |
(l_scene_mean, quality, warning, confidence_penalty)
|
| 83 |
-
quality: "good", "low", "insufficient"
|
| 84 |
-
confidence_penalty: 0.0 to 1.0 (multiplied with confidence)
|
| 85 |
"""
|
| 86 |
-
# Global scene luminance
|
| 87 |
lab_full = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2Lab).astype(np.float32)
|
| 88 |
l_scene = float(np.mean(lab_full[:, :, 0]) * (100.0 / 255.0))
|
| 89 |
|
| 90 |
-
|
|
|
|
| 91 |
if l_scene < L_SCENE_MIN or l_skin_mean < L_SKIN_MIN:
|
| 92 |
quality = "insufficient"
|
| 93 |
-
|
| 94 |
f"Iluminacion insuficiente (L* escena={l_scene:.0f}, L* piel={l_skin_mean:.0f}). "
|
| 95 |
"El tipo Fitzpatrick puede estar sobreestimado (piel aparenta mas oscura). "
|
| 96 |
-
"Recapture con mejor iluminacion
|
| 97 |
)
|
| 98 |
-
penalty = 0.
|
| 99 |
elif l_scene < L_SCENE_LOW or l_skin_mean < L_SKIN_SUSPICIOUS:
|
| 100 |
quality = "low"
|
| 101 |
-
|
| 102 |
f"Iluminacion suboptima (L* escena={l_scene:.0f}, L* piel={l_skin_mean:.0f}). "
|
| 103 |
-
"El tipo Fitzpatrick podria estar 1-2 niveles sobreestimado.
|
| 104 |
-
"Se recomienda iluminacion uniforme para mayor precision."
|
| 105 |
)
|
| 106 |
-
penalty = 0.
|
| 107 |
else:
|
| 108 |
quality = "good"
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
|
|
|
| 112 |
return l_scene, quality, warning, penalty
|
| 113 |
|
| 114 |
|
| 115 |
def estimate_fitzpatrick(
|
| 116 |
img_bgr: np.ndarray,
|
| 117 |
masks: dict,
|
| 118 |
-
periulcer_dilation_px: int =
|
| 119 |
) -> FitzpatrickResult:
|
| 120 |
"""Estimate Fitzpatrick type from a DFU image using segmentation masks.
|
| 121 |
|
| 122 |
-
Strategy:
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
|
| 128 |
Args:
|
| 129 |
img_bgr: BGR image (H, W, 3)
|
| 130 |
masks: dict with keys 'foot', 'perilesion', 'ulcer' (bool arrays H, W)
|
| 131 |
-
periulcer_dilation_px: Extra dilation around wound for safety margin
|
| 132 |
"""
|
| 133 |
h, w = img_bgr.shape[:2]
|
| 134 |
foot = masks.get("foot", np.ones((h, w), dtype=bool))
|
| 135 |
peri = masks.get("perilesion", np.zeros((h, w), dtype=bool))
|
| 136 |
ulcer = masks.get("ulcer", np.zeros((h, w), dtype=bool))
|
| 137 |
|
| 138 |
-
# Dilate ulcer+perilesion for safety margin
|
| 139 |
exclusion = (peri | ulcer).astype(np.uint8)
|
| 140 |
if periulcer_dilation_px > 0:
|
| 141 |
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (periulcer_dilation_px, periulcer_dilation_px))
|
|
@@ -147,7 +213,6 @@ def estimate_fitzpatrick(
|
|
| 147 |
healthy_pixels = int(np.sum(healthy))
|
| 148 |
|
| 149 |
if healthy_pixels < 100:
|
| 150 |
-
# Fallback: use all foot pixels minus wound
|
| 151 |
healthy = foot & ~ulcer
|
| 152 |
healthy_pixels = int(np.sum(healthy))
|
| 153 |
|
|
@@ -167,24 +232,29 @@ def estimate_fitzpatrick(
|
|
| 167 |
|
| 168 |
# Convert to L*a*b*
|
| 169 |
lab = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2Lab).astype(np.float32)
|
| 170 |
-
l_values = lab[healthy, 0] * (100.0 / 255.0)
|
| 171 |
-
b_values = lab[healthy, 2] - 128.0
|
|
|
|
|
|
|
|
|
|
| 172 |
|
| 173 |
-
|
|
|
|
| 174 |
ftype, fint = classify_fitzpatrick(ita_mean)
|
| 175 |
|
| 176 |
-
l_skin_mean = float(np.mean(
|
| 177 |
-
b_skin_mean = float(np.mean(
|
|
|
|
| 178 |
|
| 179 |
-
# Lighting quality assessment
|
| 180 |
l_scene, lighting_quality, lighting_warning, lighting_penalty = assess_lighting(
|
| 181 |
-
img_bgr, l_skin_mean
|
| 182 |
)
|
| 183 |
|
| 184 |
# Confidence: pixel count + ITA consistency + coverage + lighting
|
| 185 |
-
pixel_conf = min(
|
| 186 |
-
ita_conf = max(0.0, 1.0 - (ita_std /
|
| 187 |
-
coverage_conf = min((
|
| 188 |
base_confidence = pixel_conf * 0.3 + ita_conf * 0.4 + coverage_conf * 0.3
|
| 189 |
confidence = base_confidence * lighting_penalty
|
| 190 |
|
|
@@ -196,8 +266,8 @@ def estimate_fitzpatrick(
|
|
| 196 |
ita_std=round(ita_std, 2),
|
| 197 |
l_skin_mean=round(l_skin_mean, 2),
|
| 198 |
b_skin_mean=round(b_skin_mean, 2),
|
| 199 |
-
healthy_pixels=
|
| 200 |
-
healthy_ratio=round(
|
| 201 |
confidence=round(confidence, 3),
|
| 202 |
l_scene_mean=round(l_scene, 2),
|
| 203 |
lighting_quality=lighting_quality,
|
|
|
|
| 3 |
Calibrated on 61 DFU images with expert ground truth.
|
| 4 |
Validation: 86.9% exact match, 98.4% adjacent, r=0.975.
|
| 5 |
|
| 6 |
+
Includes:
|
| 7 |
+
- Shadow/wound contamination filtering via L* outlier rejection
|
| 8 |
+
- Adaptive trimming (tighter when ITA variance is high)
|
| 9 |
+
- Lighting quality assessment to flag poor illumination
|
| 10 |
"""
|
| 11 |
import numpy as np
|
| 12 |
import cv2
|
|
|
|
| 29 |
}
|
| 30 |
|
| 31 |
# Lighting quality thresholds (L* scale 0-100)
|
| 32 |
+
L_SCENE_MIN = 35.0
|
| 33 |
+
L_SCENE_LOW = 50.0
|
| 34 |
+
L_SKIN_MIN = 25.0
|
| 35 |
+
L_SKIN_SUSPICIOUS = 40.0
|
| 36 |
|
| 37 |
|
| 38 |
@dataclass
|
| 39 |
class FitzpatrickResult:
|
| 40 |
+
fitzpatrick_type: str
|
| 41 |
+
fitzpatrick_int: int
|
| 42 |
+
fitzpatrick_label: str
|
| 43 |
+
ita_angle: float
|
| 44 |
+
ita_std: float
|
| 45 |
+
l_skin_mean: float
|
| 46 |
+
b_skin_mean: float
|
| 47 |
+
healthy_pixels: int
|
| 48 |
+
healthy_ratio: float
|
| 49 |
+
confidence: float
|
| 50 |
+
l_scene_mean: float = 0.0
|
| 51 |
+
lighting_quality: str = "good"
|
| 52 |
+
lighting_warning: str = ""
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def filter_shadow_pixels(l_values: np.ndarray, b_values: np.ndarray) -> tuple:
|
| 56 |
+
"""Remove shadow/contamination pixels from healthy skin sample.
|
| 57 |
+
|
| 58 |
+
Strategy: In a well-sampled skin region, L* follows a unimodal distribution.
|
| 59 |
+
Shadow contamination creates a low-L* tail. We detect this by checking if the
|
| 60 |
+
distribution is bimodal or has excessive spread, and keep only the main mode.
|
| 61 |
+
|
| 62 |
+
Returns filtered (l_values, b_values).
|
| 63 |
+
"""
|
| 64 |
+
if len(l_values) < 100:
|
| 65 |
+
return l_values, b_values
|
| 66 |
+
|
| 67 |
+
# Compute L* statistics
|
| 68 |
+
l_median = np.median(l_values)
|
| 69 |
+
l_std = np.std(l_values)
|
| 70 |
+
|
| 71 |
+
# If L* spread is reasonable (std < 12), no filtering needed
|
| 72 |
+
if l_std < 12:
|
| 73 |
+
return l_values, b_values
|
| 74 |
+
|
| 75 |
+
# High spread: likely shadow contamination. Use IQR-based filtering.
|
| 76 |
+
q1 = np.percentile(l_values, 25)
|
| 77 |
+
q3 = np.percentile(l_values, 75)
|
| 78 |
+
iqr = q3 - q1
|
| 79 |
+
|
| 80 |
+
# Keep pixels within [Q1 - 0.5*IQR, Q3 + 1.5*IQR]
|
| 81 |
+
# Asymmetric: more aggressive on the low end (shadows) than high end (specular)
|
| 82 |
+
lo = q1 - 0.5 * iqr
|
| 83 |
+
hi = q3 + 1.5 * iqr
|
| 84 |
+
keep = (l_values >= lo) & (l_values <= hi)
|
| 85 |
+
|
| 86 |
+
if np.sum(keep) < 50:
|
| 87 |
+
# Fallback: just use upper half of L* values
|
| 88 |
+
keep = l_values >= l_median
|
| 89 |
+
|
| 90 |
+
return l_values[keep], b_values[keep]
|
| 91 |
|
| 92 |
|
| 93 |
def compute_ita(l_values: np.ndarray, b_values: np.ndarray) -> tuple:
|
| 94 |
+
"""Compute ITA angle from L* and b* values with adaptive trimming.
|
| 95 |
|
| 96 |
ITA = arctan((L* - 50) / b*) * (180 / pi)
|
| 97 |
Higher ITA = lighter skin, lower ITA = darker skin.
|
| 98 |
+
|
| 99 |
+
Uses adaptive percentile trimming: starts at 10-90, tightens if variance is high.
|
| 100 |
"""
|
| 101 |
ita_per_pixel = np.degrees(np.arctan2(l_values - 50.0, b_values))
|
| 102 |
+
|
| 103 |
+
# First pass: 10th-90th percentile (tighter than original 5-95)
|
| 104 |
+
p10, p90 = np.percentile(ita_per_pixel, [10, 90])
|
| 105 |
+
trimmed = ita_per_pixel[(ita_per_pixel >= p10) & (ita_per_pixel <= p90)]
|
| 106 |
+
|
| 107 |
if len(trimmed) < 10:
|
| 108 |
trimmed = ita_per_pixel
|
| 109 |
+
|
| 110 |
+
first_std = float(np.std(trimmed))
|
| 111 |
+
|
| 112 |
+
# If still high variance, tighten to 25-75 (IQR)
|
| 113 |
+
if first_std > 20 and len(ita_per_pixel) > 200:
|
| 114 |
+
p25, p75 = np.percentile(ita_per_pixel, [25, 75])
|
| 115 |
+
iqr_trimmed = ita_per_pixel[(ita_per_pixel >= p25) & (ita_per_pixel <= p75)]
|
| 116 |
+
if len(iqr_trimmed) >= 50:
|
| 117 |
+
trimmed = iqr_trimmed
|
| 118 |
+
|
| 119 |
return float(np.mean(trimmed)), float(np.std(trimmed))
|
| 120 |
|
| 121 |
|
|
|
|
| 125 |
if lo <= ita < hi:
|
| 126 |
idx = list(ITA_THRESHOLDS.keys()).index(ftype) + 1
|
| 127 |
return ftype, idx
|
| 128 |
+
return "III", 3
|
| 129 |
|
| 130 |
|
| 131 |
+
def assess_lighting(img_bgr: np.ndarray, l_skin_mean: float, ita_std: float) -> tuple:
|
| 132 |
"""Assess scene lighting quality for reliable Fitzpatrick estimation.
|
| 133 |
|
| 134 |
Returns:
|
| 135 |
(l_scene_mean, quality, warning, confidence_penalty)
|
|
|
|
|
|
|
| 136 |
"""
|
|
|
|
| 137 |
lab_full = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2Lab).astype(np.float32)
|
| 138 |
l_scene = float(np.mean(lab_full[:, :, 0]) * (100.0 / 255.0))
|
| 139 |
|
| 140 |
+
warnings = []
|
| 141 |
+
|
| 142 |
if l_scene < L_SCENE_MIN or l_skin_mean < L_SKIN_MIN:
|
| 143 |
quality = "insufficient"
|
| 144 |
+
warnings.append(
|
| 145 |
f"Iluminacion insuficiente (L* escena={l_scene:.0f}, L* piel={l_skin_mean:.0f}). "
|
| 146 |
"El tipo Fitzpatrick puede estar sobreestimado (piel aparenta mas oscura). "
|
| 147 |
+
"Recapture con mejor iluminacion."
|
| 148 |
)
|
| 149 |
+
penalty = 0.15
|
| 150 |
elif l_scene < L_SCENE_LOW or l_skin_mean < L_SKIN_SUSPICIOUS:
|
| 151 |
quality = "low"
|
| 152 |
+
warnings.append(
|
| 153 |
f"Iluminacion suboptima (L* escena={l_scene:.0f}, L* piel={l_skin_mean:.0f}). "
|
| 154 |
+
"El tipo Fitzpatrick podria estar 1-2 niveles sobreestimado."
|
|
|
|
| 155 |
)
|
| 156 |
+
penalty = 0.4
|
| 157 |
else:
|
| 158 |
quality = "good"
|
| 159 |
+
penalty = 1.0
|
| 160 |
+
|
| 161 |
+
# High ITA std indicates contaminated sample (shadows, wound edges)
|
| 162 |
+
if ita_std > 20:
|
| 163 |
+
if quality == "good":
|
| 164 |
+
quality = "low"
|
| 165 |
+
warnings.append(
|
| 166 |
+
f"Alta variabilidad en la muestra de piel (ITA std={ita_std:.1f}). "
|
| 167 |
+
"Posible contaminacion por sombras o bordes de herida."
|
| 168 |
+
)
|
| 169 |
+
penalty *= 0.5
|
| 170 |
+
elif ita_std > 15:
|
| 171 |
+
warnings.append(
|
| 172 |
+
f"Variabilidad moderada (ITA std={ita_std:.1f}). "
|
| 173 |
+
"Resultado puede tener +/- 1 nivel de error."
|
| 174 |
+
)
|
| 175 |
+
penalty *= 0.7
|
| 176 |
|
| 177 |
+
warning = " ".join(warnings)
|
| 178 |
return l_scene, quality, warning, penalty
|
| 179 |
|
| 180 |
|
| 181 |
def estimate_fitzpatrick(
|
| 182 |
img_bgr: np.ndarray,
|
| 183 |
masks: dict,
|
| 184 |
+
periulcer_dilation_px: int = 60,
|
| 185 |
) -> FitzpatrickResult:
|
| 186 |
"""Estimate Fitzpatrick type from a DFU image using segmentation masks.
|
| 187 |
|
| 188 |
+
Strategy:
|
| 189 |
+
1. Healthy skin = foot region - dilated(perilesion + ulcer)
|
| 190 |
+
2. Filter shadow/contamination pixels via L* outlier rejection
|
| 191 |
+
3. Compute ITA with adaptive trimming
|
| 192 |
+
4. Assess lighting quality and adjust confidence
|
| 193 |
|
| 194 |
Args:
|
| 195 |
img_bgr: BGR image (H, W, 3)
|
| 196 |
masks: dict with keys 'foot', 'perilesion', 'ulcer' (bool arrays H, W)
|
| 197 |
+
periulcer_dilation_px: Extra dilation around wound for safety margin (default 60)
|
| 198 |
"""
|
| 199 |
h, w = img_bgr.shape[:2]
|
| 200 |
foot = masks.get("foot", np.ones((h, w), dtype=bool))
|
| 201 |
peri = masks.get("perilesion", np.zeros((h, w), dtype=bool))
|
| 202 |
ulcer = masks.get("ulcer", np.zeros((h, w), dtype=bool))
|
| 203 |
|
| 204 |
+
# Dilate ulcer+perilesion for safety margin (increased from 40 to 60)
|
| 205 |
exclusion = (peri | ulcer).astype(np.uint8)
|
| 206 |
if periulcer_dilation_px > 0:
|
| 207 |
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (periulcer_dilation_px, periulcer_dilation_px))
|
|
|
|
| 213 |
healthy_pixels = int(np.sum(healthy))
|
| 214 |
|
| 215 |
if healthy_pixels < 100:
|
|
|
|
| 216 |
healthy = foot & ~ulcer
|
| 217 |
healthy_pixels = int(np.sum(healthy))
|
| 218 |
|
|
|
|
| 232 |
|
| 233 |
# Convert to L*a*b*
|
| 234 |
lab = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2Lab).astype(np.float32)
|
| 235 |
+
l_values = lab[healthy, 0] * (100.0 / 255.0)
|
| 236 |
+
b_values = lab[healthy, 2] - 128.0
|
| 237 |
+
|
| 238 |
+
# Filter shadow/contamination pixels BEFORE computing ITA
|
| 239 |
+
l_filtered, b_filtered = filter_shadow_pixels(l_values, b_values)
|
| 240 |
|
| 241 |
+
# Compute ITA with adaptive trimming
|
| 242 |
+
ita_mean, ita_std = compute_ita(l_filtered, b_filtered)
|
| 243 |
ftype, fint = classify_fitzpatrick(ita_mean)
|
| 244 |
|
| 245 |
+
l_skin_mean = float(np.mean(l_filtered))
|
| 246 |
+
b_skin_mean = float(np.mean(b_filtered))
|
| 247 |
+
filtered_pixels = len(l_filtered)
|
| 248 |
|
| 249 |
+
# Lighting quality assessment (now also considers ITA std)
|
| 250 |
l_scene, lighting_quality, lighting_warning, lighting_penalty = assess_lighting(
|
| 251 |
+
img_bgr, l_skin_mean, ita_std
|
| 252 |
)
|
| 253 |
|
| 254 |
# Confidence: pixel count + ITA consistency + coverage + lighting
|
| 255 |
+
pixel_conf = min(filtered_pixels / 5000.0, 1.0)
|
| 256 |
+
ita_conf = max(0.0, 1.0 - (ita_std / 25.0))
|
| 257 |
+
coverage_conf = min((filtered_pixels / (h * w)) / 0.15, 1.0)
|
| 258 |
base_confidence = pixel_conf * 0.3 + ita_conf * 0.4 + coverage_conf * 0.3
|
| 259 |
confidence = base_confidence * lighting_penalty
|
| 260 |
|
|
|
|
| 266 |
ita_std=round(ita_std, 2),
|
| 267 |
l_skin_mean=round(l_skin_mean, 2),
|
| 268 |
b_skin_mean=round(b_skin_mean, 2),
|
| 269 |
+
healthy_pixels=filtered_pixels,
|
| 270 |
+
healthy_ratio=round(filtered_pixels / (h * w), 4),
|
| 271 |
confidence=round(confidence, 3),
|
| 272 |
l_scene_mean=round(l_scene, 2),
|
| 273 |
lighting_quality=lighting_quality,
|