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,
)
|