WoundNetB7-DFU-Analysis / src /fitzpatrick_estimator.py
mmarquezsa's picture
fix: shadow filtering + adaptive ITA trimming + increased periulcer dilation (60px) — reduces Fitzpatrick overestimation
b379987 verified
Raw
History Blame Contribute Delete
9.64 kB
"""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,
)