mmarquezsa commited on
Commit
b379987
·
verified ·
1 Parent(s): 262e4a4

fix: shadow filtering + adaptive ITA trimming + increased periulcer dilation (60px) — reduces Fitzpatrick overestimation

Browse files
Files changed (1) hide show
  1. 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 lighting quality assessment to avoid misclassification
7
- under poor illumination conditions.
 
 
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 # Below this: scene too dark for reliable ITA
31
- L_SCENE_LOW = 50.0 # Below this: suboptimal lighting, reduce confidence
32
- L_SKIN_MIN = 25.0 # Healthy skin L* below this is almost certainly lighting artifact
33
- L_SKIN_SUSPICIOUS = 40.0 # L* this low is rare even for Fitzpatrick VI under good light
34
 
35
 
36
  @dataclass
37
  class FitzpatrickResult:
38
- fitzpatrick_type: str # "I" .. "VI"
39
- fitzpatrick_int: int # 1 .. 6
40
- fitzpatrick_label: str # "Very Light" .. "Dark"
41
- ita_angle: float # ITA in degrees
42
- ita_std: float # ITA standard deviation
43
- l_skin_mean: float # Mean L* of healthy skin
44
- b_skin_mean: float # Mean b* of healthy skin
45
- healthy_pixels: int # Number of healthy skin pixels used
46
- healthy_ratio: float # Healthy pixels / total image pixels
47
- confidence: float # 0-1 confidence score
48
- # Lighting quality
49
- l_scene_mean: float = 0.0 # Mean L* of entire image
50
- lighting_quality: str = "good" # "good", "low", "insufficient"
51
- lighting_warning: str = "" # Human-readable warning if lighting is poor
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 robust trimming.
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
- # Robust trimming: 5th-95th percentile
62
- p5, p95 = np.percentile(ita_per_pixel, [5, 95])
63
- trimmed = ita_per_pixel[(ita_per_pixel >= p5) & (ita_per_pixel <= p95)]
 
 
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 # Default fallback
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
- # Evaluate lighting
 
91
  if l_scene < L_SCENE_MIN or l_skin_mean < L_SKIN_MIN:
92
  quality = "insufficient"
93
- warning = (
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 para un resultado confiable."
97
  )
98
- penalty = 0.2 # Severely reduce confidence
99
  elif l_scene < L_SCENE_LOW or l_skin_mean < L_SKIN_SUSPICIOUS:
100
  quality = "low"
101
- warning = (
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.5 # Moderate confidence reduction
107
  else:
108
  quality = "good"
109
- warning = ""
110
- penalty = 1.0 # No penalty
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = 40,
119
  ) -> FitzpatrickResult:
120
  """Estimate Fitzpatrick type from a DFU image using segmentation masks.
121
 
122
- Strategy: Healthy skin = foot region - perilesion zone - ulcer.
123
- ITA is computed on the healthy skin pixels only.
124
-
125
- Includes lighting quality assessment if scene is too dark,
126
- confidence is penalized and a warning is issued.
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) # OpenCV L* is 0-255 -> 0-100
171
- b_values = lab[healthy, 2] - 128.0 # OpenCV b* is 0-255 -> -128 to +127
 
 
 
172
 
173
- ita_mean, ita_std = compute_ita(l_values, b_values)
 
174
  ftype, fint = classify_fitzpatrick(ita_mean)
175
 
176
- l_skin_mean = float(np.mean(l_values))
177
- b_skin_mean = float(np.mean(b_values))
 
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(healthy_pixels / 5000.0, 1.0)
186
- ita_conf = max(0.0, 1.0 - (ita_std / 30.0))
187
- coverage_conf = min((healthy_pixels / (h * w)) / 0.15, 1.0)
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=healthy_pixels,
200
- healthy_ratio=round(healthy_pixels / (h * w), 4),
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,