ffplima commited on
Commit
e5c8f45
·
verified ·
1 Parent(s): e6c339d

Update app/eyes_localizer.py

Browse files
Files changed (1) hide show
  1. app/eyes_localizer.py +214 -291
app/eyes_localizer.py CHANGED
@@ -21,76 +21,22 @@ def _to_b64(img_rgb):
21
  def _safe_rgb2lab(rgb):
22
  """
23
  Aceita uint8 [0..255] ou float [0..1].
24
- Converte com skimage para Lab (float).
25
  """
26
- if rgb.dtype != np.float32 and rgb.dtype != np.float64:
27
- arr = rgb.astype(np.float32) / 255.0
28
- else:
29
- arr = np.clip(rgb, 0.0, 1.0).astype(np.float32)
 
30
  lab = color.rgb2lab(arr)
31
- L = lab[...,0].astype(np.float32)
32
- a = lab[...,1].astype(np.float32)
33
- b = lab[...,2].astype(np.float32)
34
- return L, a, b
35
 
36
- def _ring_mask(h, w, cx, cy, rin, rout):
37
- if h <= 0 or w <= 0:
38
- return np.zeros((0,0), dtype=bool)
39
- Y, X = np.ogrid[:h, :w]
40
- rr = np.sqrt((X - cx)**2 + (Y - cy)**2)
41
- m = (rr >= rin) & (rr <= rout)
42
- if not np.any(m):
43
- return m
44
- return m
45
-
46
- def _lab_p50_and_med_robust(L, a, b, mask):
47
- sel = (mask.astype(bool) if mask is not None else np.zeros(0, bool))
48
- if sel.sum() < 20:
49
- return None, None, None, None, None, None
50
- Ls, As, Bs = L[sel], a[sel], b[sel]
51
- def med_mid(x):
52
- if x.size == 0: return None
53
- lo, hi = np.nanquantile(x, 0.20), np.nanquantile(x, 0.80)
54
- y = x[(x>=lo)&(x<=hi)]
55
- return float(np.nanmedian(y)) if y.size else float(np.nanmedian(x))
56
- Lm = med_mid(Ls); am = med_mid(As); bm = med_mid(Bs)
57
- C_arr = np.hypot(As, Bs).astype(np.float32)
58
- Cm = float(np.nanmedian(C_arr)) if C_arr.size else None
59
- Lp50 = float(np.nanmedian(Ls)) if Ls.size else None
60
- Cp50 = float(np.nanmedian(C_arr)) if C_arr.size else None
61
- return Lm, am, bm, Cm, Lp50, Cp50
62
-
63
- def _hsv_S(rgb_u8):
64
- if rgb_u8.dtype != np.uint8:
65
- rgb_u8 = np.clip(rgb_u8, 0, 255).astype(np.uint8)
66
- hsv = cv.cvtColor(rgb_u8, cv.COLOR_RGB2HSV)
67
- return (hsv[...,1].astype(np.float32)) / 255.0
68
-
69
- def _bbox_from_contour(c):
70
- x,y,w,h = cv.boundingRect(c)
71
- return (x,y,x+w,y+h)
72
-
73
- def _pick_eye_contours(eyes_mask):
74
- m = (eyes_mask.astype(np.uint8) if eyes_mask is not None else None)
75
- if m is None or m.sum() < 10:
76
- return []
77
- cnts, _ = cv.findContours(m, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE)
78
- if not cnts:
79
- return []
80
- cnts = sorted(cnts, key=cv.contourArea, reverse=True)[:2]
81
- return cnts
82
-
83
- def _circle_inside_bbox(cx, cy, r, bbox):
84
- x0,y0,x1,y1 = bbox
85
- return (cx>=x0 and cx<=x1 and cy>=y0 and cy<=y1 and r>2)
86
 
87
  def _fit_circle(points_xy):
88
- """
89
- Ajuste de círculo por mínimos quadrados (Taubin).
90
- Resistente à singularidade (denominador ~0).
91
- """
92
- if points_xy is None or len(points_xy) < 3:
93
- return None
94
  x = points_xy[:,0].astype(np.float64)
95
  y = points_xy[:,1].astype(np.float64)
96
  x_m = x.mean(); y_m = y.mean()
@@ -98,141 +44,122 @@ def _fit_circle(points_xy):
98
  Suu = (u*u).mean(); Svv = (v*v).mean(); Suv = (u*v).mean()
99
  Suuu = (u*u*u).mean(); Svvv = (v*v*v).mean()
100
  Suvv = (u*v*v).mean(); Svuu = (v*u*u).mean()
101
- den = 2.0*(Suu*Svv - Suv*Suv)
102
- if abs(den) < 1e-9:
103
- return None
104
  uc = ((Svv*(Suuu+Suvv) - Suv*(Svvv+Svuu)) / den)
105
  vc = ((Suu*(Svvv+Svuu) - Suv*(Suuu+Suvv)) / den)
106
  cx = x_m + uc; cy = y_m + vc
107
- r = np.sqrt(uc*uc + vc*vc + Suu + Svv)
108
- if not np.isfinite(cx) or not np.isfinite(cy) or not np.isfinite(r) or r <= 0:
109
- return None
110
  return float(cx), float(cy), float(r)
111
 
112
- def _draw_overlay(roi_rgb, cx, cy, rin, rout):
113
- vis = roi_rgb.copy()
114
- try:
115
- cv.circle(vis, (int(round(cx)), int(round(cy))), max(1,int(round(rin))), (0,255,0), 2)
116
- cv.circle(vis, (int(round(cx)), int(round(cy))), max(2,int(round(rout))), (0,255,0), 2)
117
- except Exception:
118
- pass
119
- return _to_b64(vis)
120
-
121
- def _cfg(cfg, key, default):
122
- if isinstance(cfg, dict):
123
- return cfg.get(key, default)
124
- return default
125
-
126
- # ----------------- Função principal -----------------
127
- def localize_iris_and_sample_mediapipe(orig_rgb, eyes_mask, config=None, debug=False):
 
 
 
 
 
 
128
  """
129
- Retorna: (metrics_dict | None, diag_dict)
130
- metrics_dict: {"L","a","b","C","area"} (mediana robusta no anel selecionado)
131
- diag_dict: { tech, gate_reason, ring_px, cx, cy, rin, rout, roi_bbox, overlay_b64,
132
- band_mode, c_mean_full, c_mean_band, l_mean_band,
133
- highlight_frac, sclera_frac, trim_top_deg, trim_bottom_deg,
134
- L_p50, C_p50, satrel_p50 }
 
 
 
135
  """
136
- try:
137
- H,W = int(orig_rgb.shape[0]), int(orig_rgb.shape[1])
138
- except Exception:
139
- return None, {"tech":"mp_iris","gate_reason":"bad_image"}
140
-
141
- min_eye_area_px = int(_cfg(_cfg(config,"face_parsing",{}), "min_eye_px", 180))
142
- if eyes_mask is None or int(eyes_mask.sum() or 0) < max(60, int(0.5*min_eye_area_px)):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  return None, {"tech":"mp_iris","gate_reason":"no_eyes_mask"}
144
 
145
- # ----- ROIs a partir da máscara -----
146
- cnts = _pick_eye_contours(eyes_mask)
147
- if not cnts:
148
- return None, {"tech":"mp_iris","gate_reason":"no_eye_contours"}
149
-
150
- # ordenar por X e inflar bbox com margem
151
- cnts = sorted(cnts, key=lambda c: cv.boundingRect(c)[0])
152
  rois = []
153
- pad = int(0.06 * max(H,W))
154
- for c in cnts:
155
- x0,y0,x1,y1 = _bbox_from_contour(c)
156
- x0 = max(0, x0 - pad); y0 = max(0, y0 - pad)
157
- x1 = min(W, x1 + pad); y1 = min(H, y1 + pad)
158
- if (x1-x0) <= 5 or (y1-y0) <= 5:
159
  continue
160
- roi = orig_rgb[y0:y1, x0:x1, :]
161
- mask_roi = eyes_mask[y0:y1, x0:x1]
162
- if roi.size and int(mask_roi.sum()) >= 20:
163
- rois.append((roi, mask_roi, (x0,y0,x1,y1)))
164
- if not rois:
165
- return None, {"tech":"mp_iris","gate_reason":"no_eye_rois"}
166
-
167
- # ----- FaceMesh / landmarks de íris -----
168
- if mp_face is None:
169
- return None, {"tech":"mp_iris","gate_reason":"mediapipe_unavailable"}
170
-
171
- try:
172
- bgr = cv.cvtColor(orig_rgb.astype(np.uint8), cv.COLOR_RGB2BGR)
173
- except Exception:
174
- return None, {"tech":"mp_iris","gate_reason":"bad_image_colorspace"}
175
-
176
- try:
177
- with mp_face.FaceMesh(
178
- static_image_mode=True, max_num_faces=1,
179
- refine_landmarks=True, min_detection_confidence=0.5
180
- ) as face:
181
- res = face.process(bgr)
182
- except Exception as e:
183
- return None, {"tech":"mp_iris","gate_reason":"facemesh_error","error":f"{type(e).__name__}"}
184
-
185
- if not res.multi_face_landmarks:
186
- return None, {"tech":"mp_iris","gate_reason":"no_facemesh"}
187
-
188
- lmks = res.multi_face_landmarks[0].landmark
189
- pts = np.array([[lmk.x*W, lmk.y*H] for lmk in lmks], dtype=np.float32)
190
-
191
- def circle_from_indices(cand):
192
- idx = np.array(cand, dtype=int)
193
- if idx.max() >= pts.shape[0]:
194
- return None
195
- cxy = pts[idx,:]
196
- return _fit_circle(cxy)
197
-
198
- left_circ, right_circ = None, None
199
- for cand in LEFT_IRIS_CANDIDATES:
200
- left_circ = circle_from_indices(cand)
201
- if left_circ: break
202
- for cand in RIGHT_IRIS_CANDIDATES:
203
- right_circ = circle_from_indices(cand)
204
- if right_circ: break
205
-
206
- # ----- Config de anel / guard-rails -----
207
- inner_fr = _cfg(config, "iris_inner_frac_range", [0.38, 0.46])
208
- outer_fr = _cfg(config, "iris_outer_frac_range", [0.68, 0.78])
209
- min_band_w = int(_cfg(config, "iris_band_min_width_px", 12))
210
- iris_S_min = float(_cfg(config, "iris_S_min", 0.20))
211
- L_clip_top_q = float(_cfg(config, "iris_L_clip_top_q", 0.80))
212
- sclera_frac_max = float(_cfg(config, "iris_sclera_frac_max", 0.20))
213
- highlight_frac_max = float(_cfg(config, "iris_highlight_frac_max", 0.30))
214
- min_ring_px = int(_cfg(config, "iris_min_ring_px", 140))
215
- c_weighted = bool(_cfg(config, "iris_c_weighted_mean", True))
216
-
217
- trim_cfg = _cfg(config, "iris_trim_dark_sectors", {"enabled": True, "deg_top": 12, "deg_bottom": 12, "L_rel_q": 0.15})
218
- trim_enabled = bool(_cfg(trim_cfg, "enabled", False))
219
- trim_deg_top = float(_cfg(trim_cfg, "deg_top", 12.0))
220
- trim_deg_bottom = float(_cfg(trim_cfg, "deg_bottom", 12.0))
221
- trim_L_rel_q = float(_cfg(trim_cfg, "L_rel_q", 0.15))
222
-
223
- mid_cfg = _cfg(config, "iris_midband_when_lowC",
224
- {"enabled": True, "c_mean_threshold": 8.0, "subband_halfwidth_frac": 0.06,
225
- "c_outlier_drop_q": 0.25, "l_high_clip_q": 0.85, "guard_to_inner_outer_px": 2})
226
- mid_enabled = bool(_cfg(mid_cfg, "enabled", True))
227
- mid_thrC = float(_cfg(mid_cfg, "c_mean_threshold", 8.0))
228
- mid_hw_frac = float(_cfg(mid_cfg, "subband_halfwidth_frac", 0.06))
229
- mid_guard = int(_cfg(mid_cfg, "guard_to_inner_outer_px", 2))
230
 
231
  candidates = []
232
  for (roi, mroi, bbox) in rois:
233
  x0,y0,x1,y1 = bbox
234
  for circ in (left_circ, right_circ):
235
- if circ is None:
236
  continue
237
  cx,cy,r = circ
238
  if not _circle_inside_bbox(cx,cy,r,bbox):
@@ -244,13 +171,13 @@ def localize_iris_and_sample_mediapipe(orig_rgb, eyes_mask, config=None, debug=F
244
  if h<=2 or w<=2:
245
  continue
246
 
247
- # Raio interno/externo (usar faixas + largura mínima)
248
- rin = max(2.0, float(r)*float(inner_fr[0]))
249
- rout = min(float(r)*0.98, float(r)*float(outer_fr[1]))
250
- if (rout - rin) < float(min_band_w):
251
- # ajusta mantendo dentro de (0, r*0.98)
252
  midr = 0.5*(rin + rout)
253
- rin = max(2.0, midr - 0.5*min_band_w)
254
  rout = min(float(r)*0.98, midr + 0.5*min_band_w)
255
  if rout <= rin + 0.5:
256
  rout = rin + 0.5
@@ -262,94 +189,83 @@ def localize_iris_and_sample_mediapipe(orig_rgb, eyes_mask, config=None, debug=F
262
 
263
  # LAB/HSV
264
  L, a, b = _safe_rgb2lab(roi)
265
- S = _hsv_S(roi)
266
 
267
- # highlight + sclera (L alto, S baixo)
268
  L_ring = L[ring]
269
  if L_ring.size == 0:
270
  continue
271
- L_hi = np.nanquantile(L_ring, L_clip_top_q)
272
- sclera = (L >= L_hi) & (S <= 0.15) & ring
 
 
 
 
273
  highlight_frac = float(np.mean((L >= L_hi) & ring)) if ring.any() else 1.0
274
- sclera_frac = float(np.mean(sclera)) if ring.any() else 1.0
275
 
276
- keep = ring & (~sclera) & (S >= iris_S_min)
 
277
  if int(keep.sum()) < 20:
278
  continue
279
 
280
- # (opcional) trim setorial por sombras de pálpebra
281
  trim_top_deg = None
282
  trim_bot_deg = None
283
  if trim_enabled:
284
  Y, X = np.ogrid[:h, :w]
285
  ang = (np.degrees(np.arctan2((Y - cy_r), (X - cx_r))) + 360.0) % 360.0
286
- # defino "topo" ~ 90°, "baixo" ~ 270°
287
- top_sector = keep & (np.abs(((ang - 90.0 + 180.0) % 360.0) - 180.0) <= trim_deg_top)
288
  bot_sector = keep & (np.abs(((ang - 270.0 + 180.0) % 360.0) - 180.0) <= trim_deg_bottom)
289
-
290
- # remove setores se forem mais escuros que um quantil relativo
291
  if top_sector.any():
292
  L_top = L[top_sector]
293
- L_thr = np.nanquantile(L_ring, trim_L_rel_q)
294
- if np.nanmean(L_top) < L_thr:
295
- keep[top_sector] = False
296
- trim_top_deg = float(trim_deg_top)
297
  if bot_sector.any():
298
  L_bot = L[bot_sector]
299
- L_thr = np.nanquantile(L_ring, trim_L_rel_q)
300
- if np.nanmean(L_bot) < L_thr:
301
- keep[bot_sector] = False
302
- trim_bot_deg = float(trim_deg_bottom)
303
 
304
- if int(keep.sum()) < 20:
305
- continue
 
 
 
 
 
306
 
307
- # Stats no anel "full"
308
- Lm, am, bm, Cm, Lp50, Cp50 = _lab_p50_and_med_robust(L, a, b, keep)
309
- if Lm is None:
310
- continue
311
- C_arr_full = np.hypot(a[keep], b[keep]).astype(np.float32)
312
- c_mean_full = float(np.nanmean(C_arr_full)) if C_arr_full.size else float(Cm or 0.0)
313
 
314
- # (opcional) mid-band quando C* baixo
315
  band_mode = "full"
316
  c_mean_band = c_mean_full
317
- l_mean_band = float(Lm)
318
 
319
- if mid_enabled and c_mean_full < mid_thrC and int(keep.sum()) >= 40:
 
320
  band_mode = "midband"
321
- # sub-anel em torno do raio médio
322
  mid_r = 0.5*(rin + rout)
323
- hw = max(1.0, float(mid_hw_frac) * float(r))
324
- rin2 = max(rin + mid_guard, mid_r - hw)
325
  rout2 = min(rout - mid_guard, mid_r + hw)
326
  if rout2 > rin2 + 0.5:
327
  sub = _ring_mask(h,w, cx_r, cy_r, rin2, rout2) & keep
328
  if int(sub.sum()) >= 20:
329
- keep_band = sub
330
- # clipes dentro do sub-anel
331
- L_sub = L[keep_band]; a_sub = a[keep_band]; b_sub = b[keep_band]
332
  C_sub = np.hypot(a_sub, b_sub).astype(np.float32)
333
 
334
- # drop outliers de C* (parte inferior)
335
  if C_sub.size >= 8:
336
- qdrop = int(np.floor(float(_cfg(mid_cfg,"c_outlier_drop_q",0.25))*C_sub.size))
337
  if qdrop > 0:
338
  idx = np.argsort(C_sub)
339
  keep_idx = idx[qdrop:]
340
  L_sub = L_sub[keep_idx]; a_sub = a_sub[keep_idx]; b_sub = b_sub[keep_idx]
341
  C_sub = C_sub[keep_idx]
342
- keep_band = (np.zeros_like(keep_band, dtype=bool))
343
- # reconstruir máscara band a partir dos índices é custoso; em vez disso,
344
- # só recomputamos stats de arrays filtrados.
345
- # l_high_clip
346
- if L_sub.size >= 5:
347
- L_hi2 = np.nanquantile(L_sub, float(_cfg(mid_cfg,"l_high_clip_q",0.85)))
348
- sel2 = (L_sub <= L_hi2)
349
- L_sub = L_sub[sel2]; a_sub = a_sub[sel2]; b_sub = b_sub[sel2]; C_sub = C_sub[sel2]
350
 
351
  if L_sub.size >= 10:
352
- # médias ponderadas por C* (se habilitado)
353
  if c_weighted and C_sub.size > 0 and np.nanmean(C_sub) > 0:
354
  w = C_sub / (np.nanmean(C_sub) + 1e-6)
355
  l_mean_band = float(np.average(L_sub, weights=w))
@@ -360,69 +276,76 @@ def localize_iris_and_sample_mediapipe(orig_rgb, eyes_mask, config=None, debug=F
360
  a_mean_band = float(np.nanmean(a_sub))
361
  b_mean_band = float(np.nanmean(b_sub))
362
  c_mean_band = float(np.nanmean(C_sub))
363
- # substitui stats finais (mantendo Lp50/Cp50 dos "full" para telemetria)
364
  Lm, am, bm = l_mean_band, a_mean_band, b_mean_band
365
- Cm = float(np.nanmedian(C_sub)) if C_sub.size else Cm
366
 
367
- # escore de qualidade (pouco sensível a sclera)
368
- ring_px = int(keep.sum())
369
- score = float(ring_px * max(0.05, 1.0 - min(1.0, sclera_frac)))
 
 
370
 
371
- # overlay
372
- overlay = _draw_overlay(roi, cx_r, cy_r, rin, rout)
 
373
 
374
- # filtros finais de qualidade para evitar “estouro”
375
- ring_area_est = np.pi*(rout*rout - rin*rin)
376
- min_cut = max(20, min( min_ring_px, 0.12*ring_area_est ))
377
- if ring_px < min_cut:
378
- continue
379
- if highlight_frac > (highlight_frac_max + 0.25): # só aborta se MUITO fora
380
- continue
381
-
382
- # empacota candidato
383
  candidates.append({
384
  "stats": {
385
- "L": float(Lm), "a": float(am), "b": float(bm),
386
- "C": float(np.hypot(float(am), float(bm))),
387
- "area": ring_px
388
- },
389
- "diag": {
390
- "tech": "mp_iris", "gate_reason": "ok",
391
- "ring_px": ring_px,
392
- "cx": float(cx), "cy": float(cy),
393
- "rin": float(rin), "rout": float(rout),
394
- "roi_bbox": [int(x0),int(y0),int(x1),int(y1)],
395
- "overlay_b64": overlay,
396
- # métricas de banda
397
- "band_mode": band_mode,
398
- "c_mean_full": float(c_mean_full),
399
- "c_mean_band": float(c_mean_band),
400
- "l_mean_band": float(l_mean_band),
401
- "highlight_frac": float(highlight_frac),
402
- "sclera_frac": float(sclera_frac),
403
- "trim_top_deg": (float(trim_deg_top) if isinstance(trim_top_deg,(int,float)) else None),
404
- "trim_bottom_deg": (float(trim_deg_bottom) if isinstance(trim_bot_deg,(int,float)) else None),
405
  },
406
- "score": float(score)
 
 
 
 
 
 
 
 
 
 
 
 
 
407
  })
408
 
409
  if not candidates:
410
- # Nenhum anel válido
411
- return None, {"tech":"mp_iris","gate_reason":"no_valid_ring"}
412
-
413
- best = max(candidates, key=lambda d: d["score"])
414
- # acrescenta p50 e satrel_p50 para telemetria
415
- # (recalcula p50 a partir do ROI/keep não guardado — usa aproximação via stats)
 
 
 
 
 
 
 
416
  stats = best["stats"].copy()
417
- diag = best["diag"].copy()
418
-
419
- # aproximações de p50 e satrel
420
- Lp50 = float(stats["L"])
421
- Cp50 = float(stats["C"])
422
- satrel_p50 = float(Cp50 / (Cp50 + 12.0)) if np.isfinite(Cp50) else None
423
-
424
- diag["L_p50"] = Lp50
425
- diag["C_p50"] = Cp50
426
- diag["satrel_p50"] = satrel_p50
427
-
 
 
 
 
 
 
 
 
 
428
  return stats, diag
 
21
  def _safe_rgb2lab(rgb):
22
  """
23
  Aceita uint8 [0..255] ou float [0..1].
24
+ Retorna L*, a*, b* no padrão scikit-image (L em [0..100]).
25
  """
26
+ arr = rgb
27
+ if arr.dtype != np.uint8:
28
+ arr = np.clip(arr, 0.0, 255.0).astype(np.float32)
29
+ if arr.max() <= 1.0:
30
+ arr = (arr * 255.0).astype(np.uint8)
31
  lab = color.rgb2lab(arr)
32
+ return lab[...,0].astype(np.float32), lab[...,1].astype(np.float32), lab[...,2].astype(np.float32)
 
 
 
33
 
34
+ def _hsv_S(img_rgb):
35
+ hsv = cv.cvtColor(img_rgb.astype(np.uint8), cv.COLOR_RGB2HSV)
36
+ return (hsv[...,1].astype(np.float32))/255.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
  def _fit_circle(points_xy):
39
+ """Ajuste de círculo por mínimos quadrados (Taubin)."""
 
 
 
 
 
40
  x = points_xy[:,0].astype(np.float64)
41
  y = points_xy[:,1].astype(np.float64)
42
  x_m = x.mean(); y_m = y.mean()
 
44
  Suu = (u*u).mean(); Svv = (v*v).mean(); Suv = (u*v).mean()
45
  Suuu = (u*u*u).mean(); Svvv = (v*v*v).mean()
46
  Suvv = (u*v*v).mean(); Svuu = (v*u*u).mean()
47
+ den = 2.0*(Suu*Svv - Suv*Suv) if abs(Suu*Svv - Suv*Suv) > 1e-9 else 1e-9
 
 
48
  uc = ((Svv*(Suuu+Suvv) - Suv*(Svvv+Svuu)) / den)
49
  vc = ((Suu*(Svvv+Svuu) - Suv*(Suuu+Suvv)) / den)
50
  cx = x_m + uc; cy = y_m + vc
51
+ r = float(np.sqrt(uc*uc + vc*vc + Suu + Svv))
 
 
52
  return float(cx), float(cy), float(r)
53
 
54
+ def _ring_mask(h, w, cx, cy, rin, rout):
55
+ Y, X = np.ogrid[:h, :w]
56
+ rr = np.sqrt((X - cx)**2 + (Y - cy)**2)
57
+ return (rr >= rin) & (rr <= rout)
58
+
59
+ def _circle_inside_bbox(cx, cy, r, bbox):
60
+ x0,y0,x1,y1 = bbox
61
+ return (cx-r) >= x0 and (cy-r) >= y0 and (cx+r) <= x1 and (cy+r) <= y1
62
+
63
+ def _sat_rel(a, b):
64
+ # métrica auxiliar só para telemetria
65
+ C = np.hypot(a,b).astype(np.float32)
66
+ return float(np.nanmean(C)) if C.size else None
67
+
68
+ def _draw_overlay(roi, cx, cy, rin, rout):
69
+ ov = roi.copy()
70
+ cv.circle(ov, (int(cx),int(cy)), int(rout), (0,255,0), 1)
71
+ cv.circle(ov, (int(cx),int(cy)), int(rin), (255,0,0), 1)
72
+ return _to_b64(ov)
73
+
74
+ # ----------------- Localização principal -----------------
75
+ def localize_eyes(orig_rgb, faces, eyes_mask, config):
76
  """
77
+ Retorna:
78
+ (metrics_dict | None, diag_dict)
79
+
80
+ metrics_dict: {"L","a","b","C","area"} (amostra final da íris)
81
+ diag_dict: {
82
+ "tech","gate_reason","ring_px","cx","cy","rin","rout","roi_bbox","overlay_b64",
83
+ "band_mode","band_width_px","highlight_frac","sclera_frac","c_mean_full","c_mean_band","l_mean_band",
84
+ "L_p50","C_p50","sat_rel"
85
+ }
86
  """
87
+ # ---------- parâmetros (guardrails) ----------
88
+ inner_fr = config.get("iris_inner_frac_range", [0.38, 0.46])
89
+ outer_fr = config.get("iris_outer_frac_range", [0.68, 0.78])
90
+ min_band_w = int(config.get("iris_band_min_width_px", 14))
91
+ L_clip_top_q = float(config.get("iris_L_clip_top_q", 0.92))
92
+ S_sclera_max = float(config.get("iris_S_sclera_max", 0.18))
93
+ min_ring_px = int(config.get("iris_min_ring_px", 140))
94
+ c_weighted = bool(config.get("iris_c_weighted_mean", True))
95
+
96
+ trim_cfg = config.get("iris_trim_dark_sectors", {"enabled": True,"deg_top":14,"deg_bottom":14,"L_rel_q":0.15})
97
+ trim_enabled = bool(trim_cfg.get("enabled", True))
98
+ trim_deg_top = float(trim_cfg.get("deg_top", 14.0))
99
+ trim_deg_bottom = float(trim_cfg.get("deg_bottom", 14.0))
100
+ trim_L_rel_q = float(trim_cfg.get("L_rel_q", 0.15))
101
+
102
+ mid_cfg = config.get("iris_midband_when_lowC",
103
+ {"enabled": True, "c_mean_threshold": 8.0, "subband_halfwidth_frac": 0.06,
104
+ "c_outlier_drop_q": 0.25, "l_high_clip_q": 0.85, "guard_to_inner_outer_px": 2})
105
+ mid_enabled = bool(mid_cfg.get("enabled", True))
106
+ mid_thrC = float(mid_cfg.get("c_mean_threshold", 8.0))
107
+ mid_hw_frac = float(mid_cfg.get("subband_halfwidth_frac", 0.06))
108
+ mid_guard = int(mid_cfg.get("guard_to_inner_outer_px", 2))
109
+
110
+ # ---------------- segurança de entrada ----------------
111
+ if eyes_mask is None or int(eyes_mask.sum()) < 120:
112
  return None, {"tech":"mp_iris","gate_reason":"no_eyes_mask"}
113
 
114
+ # ---- Seleciona ROIs a partir da máscara de olhos ----
115
+ contours, _ = cv.findContours(eyes_mask.astype(np.uint8), cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE)
 
 
 
 
 
116
  rois = []
117
+ H,W,_ = orig_rgb.shape
118
+ for cnt in contours:
119
+ x,y,w,h = cv.boundingRect(cnt)
120
+ x0 = max(0, x-6); y0 = max(0, y-6)
121
+ x1 = min(W, x+w+6); y1 = min(H, y+h+6)
122
+ if (x1-x0) < 10 or (y1-y0) < 10:
123
  continue
124
+ roi = orig_rgb[y0:y1, x0:x1]
125
+ mroi = eyes_mask[y0:y1, x0:x1]
126
+ rois.append((roi, mroi, (x0,y0,x1,y1)))
127
+
128
+ # ---- Círculos de íris a partir de FaceMesh (se disponível) ----
129
+ def _fit_iris_circles(faces):
130
+ if faces is None or len(faces)==0:
131
+ return None, None
132
+ # coleta landmarks por olho e ajusta círculo
133
+ def _one_eye(landmark_ids):
134
+ pts = []
135
+ for lid in landmark_ids:
136
+ try:
137
+ lm = faces.landmark[lid]
138
+ pts.append([lm.x * W, lm.y * H])
139
+ except Exception:
140
+ pass
141
+ pts = np.asarray(pts, dtype=np.float32)
142
+ if pts.shape[0] < 4:
143
+ return None
144
+ return _fit_circle(pts)
145
+ # tenta duas permutações por olho
146
+ left = None
147
+ right = None
148
+ for cand in LEFT_IRIS_CANDIDATES:
149
+ left = _one_eye(cand)
150
+ if left is not None: break
151
+ for cand in RIGHT_IRIS_CANDIDATES:
152
+ right = _one_eye(cand)
153
+ if right is not None: break
154
+ return left, right
155
+
156
+ left_circ, right_circ = _fit_iris_circles(faces)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
 
158
  candidates = []
159
  for (roi, mroi, bbox) in rois:
160
  x0,y0,x1,y1 = bbox
161
  for circ in (left_circ, right_circ):
162
+ if circ is None:
163
  continue
164
  cx,cy,r = circ
165
  if not _circle_inside_bbox(cx,cy,r,bbox):
 
171
  if h<=2 or w<=2:
172
  continue
173
 
174
+ # raios interno/externo com largura mínima e teto geométrico
175
+ rin = max(2.0, float(r) * float(inner_fr[0]))
176
+ rout = min(float(r)*0.98, float(r) * float(outer_fr[1]))
177
+ # garante largura mínima
178
+ if (rout - rin) < (min_band_w - 0.5):
179
  midr = 0.5*(rin + rout)
180
+ rin = max(2.0, midr - 0.5*min_band_w)
181
  rout = min(float(r)*0.98, midr + 0.5*min_band_w)
182
  if rout <= rin + 0.5:
183
  rout = rin + 0.5
 
189
 
190
  # LAB/HSV
191
  L, a, b = _safe_rgb2lab(roi)
192
+ S = _hsv_S(roi)
193
 
194
+ # ---- highlight + sclera (L alto + S baixo) ----
195
  L_ring = L[ring]
196
  if L_ring.size == 0:
197
  continue
198
+ L_hi = float(np.nanquantile(L_ring, L_clip_top_q))
199
+ # relaxa clip de highlight quando a massa ainda é pequena
200
+ if int(ring.sum()) < min_ring_px:
201
+ L_hi = float(np.nanquantile(L_ring, min(0.95, L_clip_top_q + 0.03)))
202
+
203
+ sclera = (L >= L_hi) & (S <= S_sclera_max) & ring
204
  highlight_frac = float(np.mean((L >= L_hi) & ring)) if ring.any() else 1.0
205
+ sclera_frac = float(np.mean(sclera)) if ring.any() else 1.0
206
 
207
+ # mantém anel, removendo sclera e highlight — sem gate de S mínimo global
208
+ keep = ring & (~sclera) & (~((L >= L_hi) & ring))
209
  if int(keep.sum()) < 20:
210
  continue
211
 
212
+ # (opcional) trim setorial (sombras de pálpebra)
213
  trim_top_deg = None
214
  trim_bot_deg = None
215
  if trim_enabled:
216
  Y, X = np.ogrid[:h, :w]
217
  ang = (np.degrees(np.arctan2((Y - cy_r), (X - cx_r))) + 360.0) % 360.0
218
+ top_sector = keep & (np.abs(((ang - 90.0 + 180.0) % 360.0) - 180.0) <= trim_deg_top)
 
219
  bot_sector = keep & (np.abs(((ang - 270.0 + 180.0) % 360.0) - 180.0) <= trim_deg_bottom)
 
 
220
  if top_sector.any():
221
  L_top = L[top_sector]
222
+ thrT = np.nanquantile(L[keep], 1.0 - trim_L_rel_q)
223
+ keep[top_sector & (L_top < thrT)] = False
224
+ trim_top_deg = float(trim_deg_top)
 
225
  if bot_sector.any():
226
  L_bot = L[bot_sector]
227
+ thrB = np.nanquantile(L[keep], 1.0 - trim_L_rel_q)
228
+ keep[bot_sector & (L_bot < thrB)] = False
229
+ trim_bot_deg = float(trim_deg_bottom)
 
230
 
231
+ # métricas no anel "full"
232
+ Lm = float(np.nanmedian(L[keep])) if keep.any() else None
233
+ am = float(np.nanmedian(a[keep])) if keep.any() else None
234
+ bm = float(np.nanmedian(b[keep])) if keep.any() else None
235
+ Cm = float(np.nanmedian(np.hypot(a[keep], b[keep]))) if keep.any() else None
236
+ c_full = np.hypot(a[keep], b[keep]).astype(np.float32) if keep.any() else np.array([],dtype=np.float32)
237
+ c_mean_full = float(np.nanmean(c_full)) if c_full.size else None
238
 
239
+ # força midband para olhos claros quando houver sinais de highlight/sclera ou pouca massa
240
+ force_mid = (int(ring.sum()) < min_ring_px) or ((rout - rin) < (min_band_w + 0.5)) or (highlight_frac >= 0.22) or (sclera_frac >= 0.18)
 
 
 
 
241
 
 
242
  band_mode = "full"
243
  c_mean_band = c_mean_full
244
+ l_mean_band = float(Lm) if Lm is not None else None
245
 
246
+ # ---- MIDBAND: quando necessário ou quando C* muito baixo ----
247
+ if force_mid or (mid_enabled and c_mean_full is not None and c_mean_full < mid_thrC and int(keep.sum()) >= 40):
248
  band_mode = "midband"
 
249
  mid_r = 0.5*(rin + rout)
250
+ hw = max(1.0, float(mid_hw_frac) * float(r))
251
+ rin2 = max(rin + mid_guard, mid_r - hw)
252
  rout2 = min(rout - mid_guard, mid_r + hw)
253
  if rout2 > rin2 + 0.5:
254
  sub = _ring_mask(h,w, cx_r, cy_r, rin2, rout2) & keep
255
  if int(sub.sum()) >= 20:
256
+ L_sub = L[sub]; a_sub = a[sub]; b_sub = b[sub]
 
 
257
  C_sub = np.hypot(a_sub, b_sub).astype(np.float32)
258
 
259
+ # drop outliers de C* (parte inferior), ajuda íris com heterocromia suave
260
  if C_sub.size >= 8:
261
+ qdrop = int(np.floor(float(mid_cfg.get("c_outlier_drop_q",0.25))*C_sub.size))
262
  if qdrop > 0:
263
  idx = np.argsort(C_sub)
264
  keep_idx = idx[qdrop:]
265
  L_sub = L_sub[keep_idx]; a_sub = a_sub[keep_idx]; b_sub = b_sub[keep_idx]
266
  C_sub = C_sub[keep_idx]
 
 
 
 
 
 
 
 
267
 
268
  if L_sub.size >= 10:
 
269
  if c_weighted and C_sub.size > 0 and np.nanmean(C_sub) > 0:
270
  w = C_sub / (np.nanmean(C_sub) + 1e-6)
271
  l_mean_band = float(np.average(L_sub, weights=w))
 
276
  a_mean_band = float(np.nanmean(a_sub))
277
  b_mean_band = float(np.nanmean(b_sub))
278
  c_mean_band = float(np.nanmean(C_sub))
279
+ # substitui stats finais (mantendo p50 do full para telemetria)
280
  Lm, am, bm = l_mean_band, a_mean_band, b_mean_band
 
281
 
282
+ # estatísticas finais
283
+ a_mean = float(am) if am is not None else None
284
+ b_mean = float(bm) if bm is not None else None
285
+ C_mean = float(np.hypot(a_mean, b_mean)) if (a_mean is not None and b_mean is not None) else (float(Cm) if Cm is not None else None)
286
+ ring_px_final = int(keep.sum())
287
 
288
+ overlay = _draw_overlay(roi, cx_r, cy_r,
289
+ rin if band_mode=="full" else (0.5*(rin + rout) - 0.5*min_band_w),
290
+ rout if band_mode=="full" else (0.5*(rin + rout) + 0.5*min_band_w))
291
 
 
 
 
 
 
 
 
 
 
292
  candidates.append({
293
  "stats": {
294
+ "L": float(Lm) if Lm is not None else None,
295
+ "a": a_mean, "b": b_mean, "C": C_mean,
296
+ "area": ring_px_final,
297
+ "L_p50": float(np.nanmedian(L[keep])) if keep.any() else None,
298
+ "C_p50": float(np.nanmedian(np.hypot(a[keep],b[keep]))) if keep.any() else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
  },
300
+ "score": float(ring_px_final * (1.0 - highlight_frac) * (1.0 - sclera_frac)),
301
+ "cx": float(cx), "cy": float(cy),
302
+ "rin": float(rin), "rout": float(rout),
303
+ "roi_bbox": [int(x0),int(y0),int(x1),int(y1)],
304
+ "overlay_b64": overlay,
305
+ "ring_px": ring_px_final,
306
+ "band_mode": band_mode,
307
+ "band_width_px": float(rout - rin),
308
+ "highlight_frac": float(highlight_frac),
309
+ "sclera_frac": float(sclera_frac),
310
+ "c_mean_full": (None if c_mean_full is None else float(c_mean_full)),
311
+ "c_mean_band": (None if c_mean_band is None else float(c_mean_band)),
312
+ "l_mean_band": (None if l_mean_band is None else float(l_mean_band)),
313
+ "tech": "mp_iris"
314
  })
315
 
316
  if not candidates:
317
+ return None, {
318
+ "tech": "mp_iris",
319
+ "gate_reason": "no_valid_ring",
320
+ "ring_px": 0, "cx": None, "cy": None,
321
+ "rin": None, "rout": None, "roi_bbox": None,
322
+ "overlay_b64": None,
323
+ "band_mode": None, "band_width_px": None,
324
+ "highlight_frac": 0.0, "sclera_frac": 0.0,
325
+ "c_mean_full": None, "c_mean_band": None, "l_mean_band": None
326
+ }
327
+
328
+ # escolhe melhor candidato pelo score
329
+ best = max(candidates, key=lambda c: c["score"])
330
  stats = best["stats"].copy()
331
+ stats["sat_rel"] = _sat_rel(stats["a"], stats["b"])
332
+
333
+ diag = {
334
+ "tech": "mp_iris",
335
+ "gate_reason": None,
336
+ "ring_px": int(best["ring_px"]),
337
+ "cx": float(best["cx"]), "cy": float(best["cy"]),
338
+ "rin": float(best["rin"]), "rout": float(best["rout"]),
339
+ "roi_bbox": best["roi_bbox"],
340
+ "overlay_b64": best["overlay_b64"],
341
+ "band_mode": best["band_mode"],
342
+ "band_width_px": float(best["band_width_px"]),
343
+ "highlight_frac": float(best["highlight_frac"]),
344
+ "sclera_frac": float(best["sclera_frac"]),
345
+ "c_mean_full": best["c_mean_full"],
346
+ "c_mean_band": best["c_mean_band"],
347
+ "l_mean_band": best["l_mean_band"],
348
+ "L_p50": stats["L_p50"],
349
+ "C_p50": stats["C_p50"]
350
+ }
351
  return stats, diag