ffplima commited on
Commit
abdc4d0
·
verified ·
1 Parent(s): 7aa5aca

Update app/eyes_localizer.py

Browse files
Files changed (1) hide show
  1. app/eyes_localizer.py +431 -136
app/eyes_localizer.py CHANGED
@@ -1,21 +1,36 @@
1
  # app/eyes_localizer.py
2
- import numpy as np, cv2 as cv, base64, math
 
 
 
 
 
 
 
 
 
 
3
  from skimage import color
4
 
 
 
 
5
  try:
6
  import mediapipe as mp
7
  mp_face = mp.solutions.face_mesh
8
  except Exception:
9
- mp_face = None
10
 
11
  LEFT_IRIS_SETS = [[474,475,476,477], [469,470,471,472]]
12
  RIGHT_IRIS_SETS = [[469,470,471,472], [474,475,476,477]]
13
 
14
- UPPER_EYELID_L = [159, 158, 157, 173]
15
- LOWER_EYELID_L = [145, 144, 163, 7]
16
- UPPER_EYELID_R = [386, 385, 384, 398]
17
- LOWER_EYELID_R = [374, 380, 381, 382]
18
 
 
 
 
19
  def _to_b64(img_rgb):
20
  ok, buf = cv.imencode(".png", cv.cvtColor(img_rgb, cv.COLOR_RGB2BGR))
21
  return base64.b64encode(buf.tobytes()).decode("ascii") if ok else ""
@@ -23,7 +38,7 @@ def _to_b64(img_rgb):
23
  def _rgb2lab_uint8(rgb):
24
  if rgb.dtype != np.uint8:
25
  rgb = np.clip(rgb, 0, 255).astype(np.uint8)
26
- lab = color.rgb2lab(rgb)
27
  return lab[...,0].astype(np.float32), lab[...,1].astype(np.float32), lab[...,2].astype(np.float32)
28
 
29
  def _hsv_S(rgb_u8):
@@ -63,44 +78,16 @@ def _fit_circle(points_xy):
63
  r = float(np.sqrt(uc*uc + vc*vc + Suu + Svv))
64
  return float(cx), float(cy), float(r)
65
 
66
- def _poly_from_lms(lms, ids, W, H):
67
- pts = []
68
- for pid in ids:
69
- if pid >= len(lms.landmark): continue
70
- p = lms.landmark[pid]
71
- pts.append((p.x*W, p.y*H))
72
- return np.asarray(pts, dtype=np.float32) if pts else None
73
-
74
- def _line_from_poly(poly):
75
- if poly is None or len(poly) < 2:
76
- return None
77
- x = poly[:,0]; y = poly[:,1]
78
- x_m = np.mean(x); y_m = np.mean(y)
79
- ux = x - x_m; uy = y - y_m
80
- cov = np.array([[np.dot(ux,ux), np.dot(ux,uy)],
81
- [np.dot(ux,uy), np.dot(uy,uy)]], dtype=np.float64)
82
- vals, vecs = np.linalg.eigh(cov)
83
- v = vecs[:,1]
84
- p0 = np.array([x_m, y_m], dtype=np.float64)
85
- p1 = p0 + v
86
- return (p0, p1)
87
-
88
- def _point_line_dist(px, py, p0, p1):
89
- v = p1 - p0
90
- w = np.array([px, py], dtype=np.float64) - p0
91
- c = np.cross(v, w)
92
- den = np.linalg.norm(v) + 1e-9
93
- return abs(c)/den
94
-
95
- def _sector_masks(h, w, cx, cy, keep, n):
96
  Y, X = np.ogrid[:h, :w]
 
97
  ang = (np.degrees(np.arctan2((Y - cy), (X - cx))) + 360.0) % 360.0
98
- step = 360.0 / float(n)
99
- masks = []
100
- for i in range(n):
101
- a0 = i*step; a1 = (i+1)*step
102
- masks.append(keep & (ang >= a0) & (ang < a1))
103
- return masks, step
104
 
105
  def _laplacian_mag(u8):
106
  L = cv.Laplacian(u8, cv.CV_32F, ksize=3)
@@ -112,12 +99,37 @@ def _overlay_from_keep(roi, keep):
112
  contours, _ = cv.findContours(mask, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE)
113
  cv.drawContours(rgb, contours, -1, (0,255,0), 1)
114
  rgb[keep] = np.clip(rgb[keep].astype(np.int16) + np.array([0,60,0], dtype=np.int16), 0, 255).astype(np.uint8)
115
- ok, buf = cv.imencode(".png", cv.cvtColor(rgb, cv.COLOR_RGB2BGR))
116
- return base64.b64encode(buf.tobytes()).decode("ascii") if ok else ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
 
 
 
 
118
  def localize_iris_and_sample_mediapipe(orig_rgb, eyes_mask, config=None, debug=False):
 
 
 
 
 
119
  cfg = config or {}
120
 
 
121
  inner_fr = cfg.get("iris_inner_frac_range", [0.38, 0.46])
122
  outer_fr = cfg.get("iris_outer_frac_range", [0.68, 0.78])
123
 
@@ -130,10 +142,21 @@ def localize_iris_and_sample_mediapipe(orig_rgb, eyes_mask, config=None, debug=F
130
  S_sclera_max = float(cfg.get("iris_S_sclera_max", 0.18))
131
  c_weighted = bool(cfg.get("iris_c_weighted_mean", True))
132
 
 
133
  trim_enabled = bool(cfg.get("eyelid_top_trim_enabled", True))
134
  trim_deg_top = float(cfg.get("eyelid_top_trim_deg", 14.0))
135
  trim_L_rel_q = float(cfg.get("eyelid_top_trim_L_rel_q", 0.15))
136
 
 
 
 
 
 
 
 
 
 
 
137
  sector_count = int(cfg.get("iris_sector_count", 8))
138
  sector_arc_min_deg = float(cfg.get("iris_sector_arc_min_deg", 90.0))
139
  sector_arc_min_deg_lowC = float(cfg.get("iris_sector_arc_min_deg_lowC", 60.0))
@@ -146,12 +169,14 @@ def localize_iris_and_sample_mediapipe(orig_rgb, eyes_mask, config=None, debug=F
146
  lowC_mean_thr = float(cfg.get("iris_lowC_mean_thr", 7.0))
147
  warm_vs_cold_c_margin = float(cfg.get("iris_warm_vs_cold_c_margin", 0.20))
148
 
 
149
  amber_a_min = float(cfg.get("iris_sector_amber_a_min", 2.0))
150
  amber_b_min = float(cfg.get("iris_sector_amber_b_min", 4.0))
151
- amber_L_q = float(cfg.get("iris_sector_amber_L_q", 0.80))
152
  amber_strict_a_min = float(cfg.get("iris_sector_amber_strict_a_min", 2.5))
153
  amber_strict_b_min = float(cfg.get("iris_sector_amber_strict_b_min", 6.0))
154
 
 
155
  anti_pupil_enabled = bool(cfg.get("anti_pupil_enabled", True))
156
  anti_pupil_guard_px = int(cfg.get("anti_pupil_guard_px", 3))
157
  anti_pupil_c_low_thr = float(cfg.get("anti_pupil_c_low_thr", 6.0))
@@ -160,39 +185,60 @@ def localize_iris_and_sample_mediapipe(orig_rgb, eyes_mask, config=None, debug=F
160
  lowL_drop_q = float(cfg.get("lowL_drop_q", 0.10))
161
  lowL_drop_max_frac = float(cfg.get("lowL_drop_max_frac", 0.20))
162
 
 
163
  pre_nudge_enabled = bool(cfg.get("pre_sector_peripheral_nudge_enabled", True))
164
  pre_nudge_c_low_thr = float(cfg.get("pre_sector_c_low_thr", 6.0))
165
  pre_nudge_high_max = float(cfg.get("pre_sector_highlight_max", 0.02))
166
  pre_nudge_sclera_max = float(cfg.get("pre_sector_sclera_max", 0.02))
167
  pre_nudge_pct = float(cfg.get("pre_sector_nudge_pct", 0.025))
168
 
 
169
  edge_guard_enabled = bool(cfg.get("edge_guard_enabled", True))
170
  edge_guard_lap_thr = float(cfg.get("edge_guard_lap_thr", 22.0))
171
  edge_guard_L_rel_q = float(cfg.get("edge_guard_L_rel_q", 0.35))
172
 
173
- lash_cfg = cfg.get("eyelash_guard", {})
174
- lash_enabled = bool(lash_cfg.get("enabled", True))
175
- lash_top_extra = float(lash_cfg.get("top_extra_deg", 6.0))
176
- lash_lap_thr = float(lash_cfg.get("lap_thr", 26.0))
177
- lash_L_rel_q = float(lash_cfg.get("L_rel_q", 0.40))
178
- lash_drop_q = float(lash_cfg.get("drop_q", 0.20))
179
- lash_max_frac = float(lash_cfg.get("max_frac_of_keep", 0.15))
180
-
181
- # >>> NOVO: parâmetros de exclusão angular hard (finalmente aplicados)
182
- excl_top_deg = float(cfg.get("iris_sector_exclude_top_deg", 0.0)) # topo = 270°
183
- excl_bot_deg = float(cfg.get("iris_sector_exclude_bottom_deg", 0.0))# base = 90°
184
-
185
- # Gate castanho
186
- brown_gate = cfg.get("brown_eye_gate", {})
187
- brown_enabled = bool(brown_gate.get("enabled", True))
188
- br_b_q50_min = float(brown_gate.get("b_q50_min", 1.8))
189
- br_L_med_min = float(brown_gate.get("L_med_min", 28.0))
190
- br_L_med_max = float(brown_gate.get("L_med_max", 58.0))
191
- br_C_med_min = float(brown_gate.get("C_med_min", 6.0))
192
- cool_b_lt = float(brown_gate.get("cool_protect_b_q50_lt", 1.0))
193
- cool_a_le = float(brown_gate.get("cool_protect_a_q50_le", 0.0))
194
- cool_L_ge = float(brown_gate.get("cool_protect_L_med_ge", 55.0))
195
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  upcfg = cfg.get("eyes_roi_upscale", {"enabled": False})
197
  roi_up_enabled = bool(upcfg.get("enabled", False))
198
  roi_target_r = float(upcfg.get("target_r_px", 140.0))
@@ -204,14 +250,15 @@ def localize_iris_and_sample_mediapipe(orig_rgb, eyes_mask, config=None, debug=F
204
 
205
  H, W = int(orig_rgb.shape[0]), int(orig_rgb.shape[1])
206
 
 
207
  cnts = _contours_from_mask(eyes_mask)
208
  cnts = cnts[:2]
209
  rois = []
210
  for c in cnts:
211
- if cv.contourArea(c) < int(cfg.get("face_parsing",{}).get("min_eye_px", 180)): continue
212
- x, y, w2, h2 = cv.boundingRect(c)
213
  x0 = max(0, x-6); y0 = max(0, y-6)
214
- x1 = min(W, x+w2+6); y1 = min(H, y+h2+6)
215
  if (x1-x0) < 10 or (y1-y0) < 10: continue
216
  roi = orig_rgb[y0:y1, x0:x1]
217
  mroi = eyes_mask[y0:y1, x0:x1].astype(bool)
@@ -219,9 +266,13 @@ def localize_iris_and_sample_mediapipe(orig_rgb, eyes_mask, config=None, debug=F
219
  if not rois:
220
  return None, {"tech":"mp_iris","gate_reason":"no_eyes_mask"}
221
 
 
222
  face_result = None
223
  if mp_face is not None:
224
- with mp_face.FaceMesh(static_image_mode=True, refine_landmarks=True, max_num_faces=1, min_detection_confidence=0.5) as fm:
 
 
 
225
  bgr = cv.cvtColor(orig_rgb.astype(np.uint8), cv.COLOR_RGB2BGR)
226
  res = fm.process(bgr)
227
  if res and res.multi_face_landmarks:
@@ -245,19 +296,24 @@ def localize_iris_and_sample_mediapipe(orig_rgb, eyes_mask, config=None, debug=F
245
  right_circ = _iris_circle_from_lms(face_result, cand)
246
  if right_circ is not None: break
247
 
248
- def _stats_on_mask(L, a, b, mk):
249
- if not mk.any():
250
- return {"px":0,"L":None,"a":None,"b":None,"C":None}
251
- Lm = float(np.nanmedian(L[mk]))
252
- am = float(np.nanmedian(a[mk])); bm = float(np.nanmedian(b[mk]))
253
- Cm = float(np.nanmedian(np.hypot(a[mk], b[mk])))
254
- return {"px":int(mk.sum()),"L":Lm,"a":am,"b":bm,"C":Cm}
255
-
256
- candidates = []
257
-
 
 
258
  def _score_one(roi, mroi, bbox, cx_abs, cy_abs, r_abs, tech_label):
259
  x0,y0,x1,y1 = bbox
260
 
 
 
 
261
  r_abs_pre = float(r_abs)
262
  roi_scale = 1.0
263
  if roi_up_enabled and r_abs_pre > 0:
@@ -273,16 +329,19 @@ def localize_iris_and_sample_mediapipe(orig_rgb, eyes_mask, config=None, debug=F
273
  r_abs = r_abs_pre * s
274
  roi_scale = float(s)
275
 
 
276
  band_min_w = max(14, int(round(base_band_min_w * roi_scale)))
277
- min_ring_px = int(round(base_min_ring_px * (roi_scale**2) * (roi_area_fac if roi_scale>1.0 else 1.0)))
278
  guard_px = max(1, int(round(base_guard_px * roi_scale)))
279
  min_px_after = int(round(base_min_px_after * (roi_scale**2) * (0.8 if roi_scale>1.0 else 1.0)))
280
 
 
281
  cx = cx_abs - x0; cy = cy_abs - y0
282
  h,w = roi.shape[:2]
283
  if cx < 0 or cy < 0 or cx >= w or cy >= h or r_abs <= 2.0:
284
  return None
285
 
 
286
  rin = max(2.0, float(r_abs) * float(inner_fr[0]))
287
  rout = min(float(r_abs)*0.98, float(r_abs) * float(outer_fr[1]))
288
  if (rout - rin) < (band_min_w - 0.5):
@@ -298,7 +357,9 @@ def localize_iris_and_sample_mediapipe(orig_rgb, eyes_mask, config=None, debug=F
298
  rgb_u8 = roi.astype(np.uint8)
299
  L, a, b = _rgb2lab_uint8(rgb_u8)
300
  S = _hsv_S(rgb_u8)
 
301
 
 
302
  L_ring = L[ring]
303
  if L_ring.size == 0:
304
  return None
@@ -312,51 +373,108 @@ def localize_iris_and_sample_mediapipe(orig_rgb, eyes_mask, config=None, debug=F
312
  if int(keep.sum()) < 20:
313
  return None
314
 
315
- # >>> AQUI: EXCLUSÃO ANGULAR HARD (Topo=270°, Base=90°) <<<
316
- # Aplica antes de qualquer outra etapa setorial; respeita massa mínima.
317
- top_cut_px = 0
318
- bot_cut_px = 0
319
- if (excl_top_deg > 0.0) or (excl_bot_deg > 0.0):
320
- Yg, Xg = np.ogrid[:h, :w]
321
- ang = (np.degrees(np.arctan2((Yg - cy), (Xg - cx))) + 360.0) % 360.0
322
- def _band(center_deg, half_width):
323
- # distância angular mínima no círculo
324
- return np.abs(((ang - center_deg + 180.0) % 360.0) - 180.0) <= half_width
325
-
326
- keep2 = keep.copy()
327
- if excl_top_deg > 0.0:
328
- top_band = _band(270.0, excl_top_deg)
329
- cand = keep & top_band
330
- cut = int(cand.sum())
331
- if cut > 0 and (int(keep.sum()) - cut) >= max(20, min_px_after):
332
- keep2[cand] = False
333
- top_cut_px = cut
334
- if excl_bot_deg > 0.0:
335
- bot_band = _band(90.0, excl_bot_deg)
336
- cand = keep2 & bot_band
337
- cut = int(cand.sum())
338
- if cut > 0 and (int(keep2.sum()) - cut) >= max(20, min_px_after):
339
- keep2[cand] = False
340
- bot_cut_px = cut
341
- if int(keep2.sum()) >= 20:
342
- keep = keep2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
 
344
- # ---- (restante do pipeline permanece igual) ----
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
 
 
346
  anti_pupil_applied = False
347
  lowL_drop_px = 0
348
 
349
  L_keep_all = L[keep]
350
  C_keep_all = np.hypot(a[keep], b[keep]).astype(np.float32)
351
- c_mean_full = float(np.nanmean(C_keep_all)) if C_keep_all.size else 0.0
352
  L_p50_all = float(np.nanmedian(L_keep_all)) if L_keep_all.size else 0.0
353
 
354
- if anti_pupil_enabled and (c_mean_full < anti_pupil_c_low_thr or L_p50_all < anti_pupil_L_p50_low):
355
- rin += float(anti_pupil_guard_px) * roi_scale
356
- if (rout - rin) < band_min_w:
357
- rout = min(float(r_abs)*0.98, rin + band_min_w)
358
- keep = _ring_mask(h,w, cx, cy, rin, rout) & (~mask_high) & (~mask_scl) & mroi
359
- anti_pupil_applied = True
 
 
360
 
361
  if lowL_drop_enabled:
362
  L_ring2 = L[keep]
@@ -373,11 +491,153 @@ def localize_iris_and_sample_mediapipe(orig_rgb, eyes_mask, config=None, debug=F
373
  if int(keep.sum()) < 20:
374
  return None
375
 
376
- # … (demais etapas: eyelid_lm_cut/edge/trim/brown_gate/nudges/seleção, mantidas)
377
- # Para manter a resposta objetiva, todas as outras lógicas permanecem como já estavam
378
- # na tua base atual — não alterei pesos nem thresholds aqui.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
 
380
- # MÉTRICA FINAL (medianas, com ponderação lowC se ativada)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  L_f = L[keep]; a_f = a[keep]; b_f = b[keep]
382
  C_f = np.hypot(a_f, b_f).astype(np.float32)
383
  if C_f.size == 0:
@@ -404,26 +664,52 @@ def localize_iris_and_sample_mediapipe(orig_rgb, eyes_mask, config=None, debug=F
404
  "rin": float(rin), "rout": float(rout),
405
  "roi_bbox": [int(x0),int(y0),int(x1),int(y1)],
406
  "overlay_b64": overlay_b64,
407
- "band_mode": "full",
408
  "band_width_px": float(rout-rin),
409
  "highlight_frac": float(np.mean(mask_high)) if ring.any() else 1.0,
410
  "sclera_frac": float(np.mean(mask_scl)) if ring.any() else 1.0,
411
- "c_mean_full": c_mean_full,
412
- "l_mean_full": float(np.nanmean(L_f)) if L_f.size else 0.0,
413
- # >>> novos diags
414
- "angular_top_cut_px": int(top_cut_px),
415
- "angular_bottom_cut_px": int(bot_cut_px)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
416
  }
417
 
 
 
 
 
418
  return {
419
- "score": float(ring_px_final),
420
- "stats": {
421
- "L": Lm, "a": am, "b": bm, "C": Cm, "area": ring_px_final,
422
- "L_p50": float(np.nanmedian(L_f)), "C_p50": float(np.nanmedian(C_f))
423
- },
424
  "diag": diag
425
  }
426
 
 
 
427
  if face_result is not None:
428
  for (roi, mroi, bbox) in rois:
429
  for circ in (left_circ, right_circ):
@@ -434,6 +720,7 @@ def localize_iris_and_sample_mediapipe(orig_rgb, eyes_mask, config=None, debug=F
434
  cand = _score_one(roi, mroi, bbox, cx_abs, cy_abs, r_abs, tech_label="mp_iris")
435
  if cand is not None: candidates.append(cand)
436
 
 
437
  if not candidates:
438
  for (roi, mroi, bbox) in rois:
439
  m = mroi.astype(np.uint8)
@@ -448,7 +735,15 @@ def localize_iris_and_sample_mediapipe(orig_rgb, eyes_mask, config=None, debug=F
448
  if cand is not None: candidates.append(cand)
449
 
450
  if not candidates:
451
- return None, {"tech":"mp_iris","gate_reason":"no_valid_ring","ring_px":0,"overlay_b64":None}
 
 
 
 
 
 
 
 
452
 
453
  best = max(candidates, key=lambda c: c["score"])
454
  stats = best["stats"].copy()
 
1
  # app/eyes_localizer.py
2
+ # ====== Iris localizer (MediaPipe + heurísticas) ======
3
+ # Ajustes cirúrgicos:
4
+ # - TOP_DEG=270.0 / BOTTOM_DEG=90.0 unificados
5
+ # - Hard-gate angular (top/base) ANTES de midband/sector/nudge
6
+ # - Guardas locais de textura/L na zona top/base
7
+ # - Banimento setorial top/base
8
+ # - Nudge radial "down-only"
9
+ # - Cool-preserve para olhos frios/claros
10
+ # - Diag fields completos para auditoria
11
+
12
+ import numpy as np, cv2 as cv, base64
13
  from skimage import color
14
 
15
+ # =============================
16
+ # MediaPipe (FaceMesh com refine_landmarks=True)
17
+ # =============================
18
  try:
19
  import mediapipe as mp
20
  mp_face = mp.solutions.face_mesh
21
  except Exception:
22
+ mp_face = None # fallback seguro
23
 
24
  LEFT_IRIS_SETS = [[474,475,476,477], [469,470,471,472]]
25
  RIGHT_IRIS_SETS = [[469,470,471,472], [474,475,476,477]]
26
 
27
+ # Ângulos de referência (fixos!)
28
+ TOP_DEG = 270.0 # cílios superiores / pálpebra superior
29
+ BOTTOM_DEG = 90.0 # pálpebra inferior
 
30
 
31
+ # =============================
32
+ # Utils
33
+ # =============================
34
  def _to_b64(img_rgb):
35
  ok, buf = cv.imencode(".png", cv.cvtColor(img_rgb, cv.COLOR_RGB2BGR))
36
  return base64.b64encode(buf.tobytes()).decode("ascii") if ok else ""
 
38
  def _rgb2lab_uint8(rgb):
39
  if rgb.dtype != np.uint8:
40
  rgb = np.clip(rgb, 0, 255).astype(np.uint8)
41
+ lab = color.rgb2lab(rgb) # L [0..100]
42
  return lab[...,0].astype(np.float32), lab[...,1].astype(np.float32), lab[...,2].astype(np.float32)
43
 
44
  def _hsv_S(rgb_u8):
 
78
  r = float(np.sqrt(uc*uc + vc*vc + Suu + Svv))
79
  return float(cx), float(cy), float(r)
80
 
81
+ def _angular_field(h, w, cx, cy):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  Y, X = np.ogrid[:h, :w]
83
+ # 0° = eixo +X; ângulo cresce CCW; já normalizado [0..360)
84
  ang = (np.degrees(np.arctan2((Y - cy), (X - cx))) + 360.0) % 360.0
85
+ return ang
86
+
87
+ def _ang_dist_deg(a, b):
88
+ # distância modular mínima entre ângulos em graus
89
+ d = abs((a - b + 180.0) % 360.0 - 180.0)
90
+ return d
91
 
92
  def _laplacian_mag(u8):
93
  L = cv.Laplacian(u8, cv.CV_32F, ksize=3)
 
99
  contours, _ = cv.findContours(mask, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE)
100
  cv.drawContours(rgb, contours, -1, (0,255,0), 1)
101
  rgb[keep] = np.clip(rgb[keep].astype(np.int16) + np.array([0,60,0], dtype=np.int16), 0, 255).astype(np.uint8)
102
+ return _to_b64(rgb)
103
+
104
+ def _largest_contiguous_arc(flags):
105
+ n = len(flags)
106
+ if n == 0: return (0,0)
107
+ doubled = flags + flags
108
+ best_len, best_start = 0, 0
109
+ cur_len, cur_start = 0, 0
110
+ for i, v in enumerate(doubled):
111
+ if v:
112
+ if cur_len == 0: cur_start = i
113
+ cur_len += 1
114
+ if cur_len > best_len and cur_len <= n:
115
+ best_len = cur_len; best_start = cur_start
116
+ else:
117
+ cur_len = 0
118
+ if best_len > n: best_len = n
119
+ return (best_start % n, best_len)
120
 
121
+ # =============================
122
+ # Core
123
+ # =============================
124
  def localize_iris_and_sample_mediapipe(orig_rgb, eyes_mask, config=None, debug=False):
125
+ """
126
+ Retorna: (metrics_dict | None, diag_dict)
127
+ metrics_dict: {"L","a","b","C","area","L_p50","C_p50","sat_rel"}
128
+ diag_dict: campos detalhados (ver final)
129
+ """
130
  cfg = config or {}
131
 
132
+ # Guard-rails / leituras de config existentes
133
  inner_fr = cfg.get("iris_inner_frac_range", [0.38, 0.46])
134
  outer_fr = cfg.get("iris_outer_frac_range", [0.68, 0.78])
135
 
 
142
  S_sclera_max = float(cfg.get("iris_S_sclera_max", 0.18))
143
  c_weighted = bool(cfg.get("iris_c_weighted_mean", True))
144
 
145
+ # trims topo / sombra
146
  trim_enabled = bool(cfg.get("eyelid_top_trim_enabled", True))
147
  trim_deg_top = float(cfg.get("eyelid_top_trim_deg", 14.0))
148
  trim_L_rel_q = float(cfg.get("eyelid_top_trim_L_rel_q", 0.15))
149
 
150
+ # midband/lowC
151
+ mid_cfg = cfg.get("iris_midband_when_lowC",
152
+ {"enabled": True, "c_mean_threshold": 8.0, "subband_halfwidth_frac": 0.06,
153
+ "c_outlier_drop_q": 0.25, "guard_to_inner_outer_px": 2})
154
+ mid_enabled = bool(mid_cfg.get("enabled", True))
155
+ mid_thrC = float(mid_cfg.get("c_mean_threshold", 8.0))
156
+ mid_hw_frac = float(mid_cfg.get("subband_halfwidth_frac", 0.06))
157
+ mid_guard = int(mid_cfg.get("guard_to_inner_outer_px", 2))
158
+
159
+ # setorização
160
  sector_count = int(cfg.get("iris_sector_count", 8))
161
  sector_arc_min_deg = float(cfg.get("iris_sector_arc_min_deg", 90.0))
162
  sector_arc_min_deg_lowC = float(cfg.get("iris_sector_arc_min_deg_lowC", 60.0))
 
169
  lowC_mean_thr = float(cfg.get("iris_lowC_mean_thr", 7.0))
170
  warm_vs_cold_c_margin = float(cfg.get("iris_warm_vs_cold_c_margin", 0.20))
171
 
172
+ # higiene quente (âmbar)
173
  amber_a_min = float(cfg.get("iris_sector_amber_a_min", 2.0))
174
  amber_b_min = float(cfg.get("iris_sector_amber_b_min", 4.0))
175
+ amber_L_q = float(cfg.get("iris_sector_amber_L_q", 0.80))
176
  amber_strict_a_min = float(cfg.get("iris_sector_amber_strict_a_min", 2.5))
177
  amber_strict_b_min = float(cfg.get("iris_sector_amber_strict_b_min", 6.0))
178
 
179
+ # anti-pupila / low-L drop
180
  anti_pupil_enabled = bool(cfg.get("anti_pupil_enabled", True))
181
  anti_pupil_guard_px = int(cfg.get("anti_pupil_guard_px", 3))
182
  anti_pupil_c_low_thr = float(cfg.get("anti_pupil_c_low_thr", 6.0))
 
185
  lowL_drop_q = float(cfg.get("lowL_drop_q", 0.10))
186
  lowL_drop_max_frac = float(cfg.get("lowL_drop_max_frac", 0.20))
187
 
188
+ # nudge periférico pré-setorização
189
  pre_nudge_enabled = bool(cfg.get("pre_sector_peripheral_nudge_enabled", True))
190
  pre_nudge_c_low_thr = float(cfg.get("pre_sector_c_low_thr", 6.0))
191
  pre_nudge_high_max = float(cfg.get("pre_sector_highlight_max", 0.02))
192
  pre_nudge_sclera_max = float(cfg.get("pre_sector_sclera_max", 0.02))
193
  pre_nudge_pct = float(cfg.get("pre_sector_nudge_pct", 0.025))
194
 
195
+ # edge-guard (cílios)
196
  edge_guard_enabled = bool(cfg.get("edge_guard_enabled", True))
197
  edge_guard_lap_thr = float(cfg.get("edge_guard_lap_thr", 22.0))
198
  edge_guard_L_rel_q = float(cfg.get("edge_guard_L_rel_q", 0.35))
199
 
200
+ # Eyelash Guard (já existente)
201
+ lash_cfg = cfg.get("eyelash_guard", {
202
+ "enabled": True,
203
+ "top_extra_deg": 10.0,
204
+ "lap_thr": 24.0,
205
+ "L_rel_q": 0.50,
206
+ "drop_q": 0.30,
207
+ "max_frac_of_keep": 0.25,
208
+ "theta_deg": 14.0,
209
+ "lap_ratio_thr": 1.22,
210
+ "top_frac_min": 0.05,
211
+ "c_drop_delta_frac": 0.06,
212
+ "L_drop_min": 3.0,
213
+ "max_frac_hard": 0.45
214
+ })
215
+
216
+ # NOVOS: hard-gate, guards locais, bans setoriais, cool-preserve, nudge down-only
217
+ hard_cfg = cfg.get("eyelid_hard_gate", {
218
+ "enabled": True,
219
+ "top_deg_width": 22.0,
220
+ "bottom_deg_width": 18.0,
221
+ "apply_when": { "lap_ratio_thr": 1.18, "L_rel_q": 0.55, "min_zone_frac": 0.045 }
222
+ })
223
+ local_guard_cfg = cfg.get("local_guard", {
224
+ "enabled": True,
225
+ "top": {"lap_thr": 23.0, "L_rel_q": 0.55, "drop_q": 0.35, "max_frac": 0.35},
226
+ "bottom": {"lap_thr": 22.0, "L_rel_q": 0.50, "drop_q": 0.30, "max_frac": 0.30}
227
+ })
228
+ sector_ban_cfg = cfg.get("sector_ban", {
229
+ "top_deg": 28.0,
230
+ "bottom_deg": 24.0
231
+ })
232
+ cool_preserve_cfg = cfg.get("cool_preserve", {
233
+ "enabled": True,
234
+ "b_med_max": 0.0,
235
+ "L_min": 46.0,
236
+ "C_max": 6.8
237
+ })
238
+ nudge_down_only = cfg.get("iris_radial_nudge_down_only", {"enabled": True, "pct_max": 0.05})
239
+ min_eye_area_px = int(cfg.get("face_parsing",{}).get("min_eye_px", 180))
240
+
241
+ # Upscale ROI
242
  upcfg = cfg.get("eyes_roi_upscale", {"enabled": False})
243
  roi_up_enabled = bool(upcfg.get("enabled", False))
244
  roi_target_r = float(upcfg.get("target_r_px", 140.0))
 
250
 
251
  H, W = int(orig_rgb.shape[0]), int(orig_rgb.shape[1])
252
 
253
+ # ---- ROIs a partir da máscara semântica ----
254
  cnts = _contours_from_mask(eyes_mask)
255
  cnts = cnts[:2]
256
  rois = []
257
  for c in cnts:
258
+ if cv.contourArea(c) < min_eye_area_px: continue
259
+ x, y, w, h = cv.boundingRect(c)
260
  x0 = max(0, x-6); y0 = max(0, y-6)
261
+ x1 = min(W, x+w+6); y1 = min(H, y+h+6)
262
  if (x1-x0) < 10 or (y1-y0) < 10: continue
263
  roi = orig_rgb[y0:y1, x0:x1]
264
  mroi = eyes_mask[y0:y1, x0:x1].astype(bool)
 
266
  if not rois:
267
  return None, {"tech":"mp_iris","gate_reason":"no_eyes_mask"}
268
 
269
+ # ---- MediaPipe ----
270
  face_result = None
271
  if mp_face is not None:
272
+ with mp_face.FaceMesh(static_image_mode=True,
273
+ refine_landmarks=True,
274
+ max_num_faces=1,
275
+ min_detection_confidence=0.5) as fm:
276
  bgr = cv.cvtColor(orig_rgb.astype(np.uint8), cv.COLOR_RGB2BGR)
277
  res = fm.process(bgr)
278
  if res and res.multi_face_landmarks:
 
296
  right_circ = _iris_circle_from_lms(face_result, cand)
297
  if right_circ is not None: break
298
 
299
+ # ---- Helpers de setorização ----
300
+ def _sector_masks(h, w, cx, cy, keep, n):
301
+ Y, X = np.ogrid[:h, :w]
302
+ ang = (np.degrees(np.arctan2((Y - cy), (X - cx))) + 360.0) % 360.0
303
+ step = 360.0 / float(n)
304
+ masks = []
305
+ for i in range(n):
306
+ a0 = i*step; a1 = (i+1)*step
307
+ masks.append(keep & (ang >= a0) & (ang < a1))
308
+ return masks, step
309
+
310
+ # ---- Avaliador de um candidato (um olho) ----
311
  def _score_one(roi, mroi, bbox, cx_abs, cy_abs, r_abs, tech_label):
312
  x0,y0,x1,y1 = bbox
313
 
314
+ # ============================
315
+ # NORMALIZAÇÃO DO ROI (UP-ONLY)
316
+ # ============================
317
  r_abs_pre = float(r_abs)
318
  roi_scale = 1.0
319
  if roi_up_enabled and r_abs_pre > 0:
 
329
  r_abs = r_abs_pre * s
330
  roi_scale = float(s)
331
 
332
+ # parâmetros escalados
333
  band_min_w = max(14, int(round(base_band_min_w * roi_scale)))
334
+ min_ring_px = int(round(base_min_ring_px * (roi_scale**2) * (0.8 if roi_scale>1.0 else 1.0)))
335
  guard_px = max(1, int(round(base_guard_px * roi_scale)))
336
  min_px_after = int(round(base_min_px_after * (roi_scale**2) * (0.8 if roi_scale>1.0 else 1.0)))
337
 
338
+ # coords relativas ao ROI
339
  cx = cx_abs - x0; cy = cy_abs - y0
340
  h,w = roi.shape[:2]
341
  if cx < 0 or cy < 0 or cx >= w or cy >= h or r_abs <= 2.0:
342
  return None
343
 
344
+ # Banda base
345
  rin = max(2.0, float(r_abs) * float(inner_fr[0]))
346
  rout = min(float(r_abs)*0.98, float(r_abs) * float(outer_fr[1]))
347
  if (rout - rin) < (band_min_w - 0.5):
 
357
  rgb_u8 = roi.astype(np.uint8)
358
  L, a, b = _rgb2lab_uint8(rgb_u8)
359
  S = _hsv_S(rgb_u8)
360
+ ang = _angular_field(h, w, cx, cy)
361
 
362
+ # Clipes globais
363
  L_ring = L[ring]
364
  if L_ring.size == 0:
365
  return None
 
373
  if int(keep.sum()) < 20:
374
  return None
375
 
376
+ # ====== HARD-GATE (TOP/BOTTOM) — ANTES DE TUDO ======
377
+ hard_applied_top = hard_applied_bottom = False
378
+ keep_px_before_hard = int(keep.sum())
379
+ top_hard_px = bottom_hard_px = 0
380
+
381
+ if bool(hard_cfg.get("enabled", True)):
382
+ top_w = float(hard_cfg.get("top_deg_width", 22.0))
383
+ bot_w = float(hard_cfg.get("bottom_deg_width", 18.0))
384
+ cond = hard_cfg.get("apply_when", {"lap_ratio_thr":1.18,"L_rel_q":0.55,"min_zone_frac":0.045})
385
+
386
+ gray = cv.cvtColor(rgb_u8, cv.COLOR_RGB2GRAY)
387
+ lap = _laplacian_mag(gray)
388
+ L_ref_top = float(np.nanquantile(L[keep], float(cond.get("L_rel_q", 0.55)))) if int(keep.sum()) else 0.0
389
+
390
+ top_zone = keep & (_ang_dist_deg(ang, TOP_DEG) <= top_w*0.5)
391
+ bot_zone = keep & (_ang_dist_deg(ang, BOTTOM_DEG) <= bot_w*0.5)
392
+
393
+ # razão de textura (laplacian) no topo vs restante
394
+ rest_zone = keep & (~top_zone)
395
+ lap_top_mean = float(np.nanmean(lap[top_zone])) if np.any(top_zone) else 0.0
396
+ lap_rest_mean = float(np.nanmean(lap[rest_zone])) if np.any(rest_zone) else 1.0
397
+ lap_ratio = (lap_top_mean / (lap_rest_mean+1e-6)) if lap_rest_mean>0 else 0.0
398
+
399
+ min_frac = float(cond.get("min_zone_frac", 0.045))
400
+ if np.any(top_zone) and float(np.mean(top_zone)) >= min_frac and (lap_ratio >= float(cond.get("lap_ratio_thr",1.18)) or float(np.nanmedian(L[top_zone])) <= L_ref_top):
401
+ to_drop = int(np.sum(top_zone))
402
+ keep[top_zone] = False
403
+ top_hard_px = to_drop
404
+ hard_applied_top = True
405
+
406
+ if np.any(bot_zone) and float(np.mean(bot_zone)) >= (min_frac*0.8):
407
+ # simples corte da base (suave, com critério de L)
408
+ L_ref_bot = float(np.nanquantile(L[keep], 0.50)) if int(keep.sum()) else 0.0
409
+ if float(np.nanmedian(L[bot_zone])) <= L_ref_bot:
410
+ to_drop = int(np.sum(bot_zone))
411
+ keep[bot_zone] = False
412
+ bottom_hard_px = to_drop
413
+ hard_applied_bottom = True
414
+
415
+ keep_px_after_hard = int(keep.sum())
416
+ if int(keep.sum()) < 20:
417
+ return None
418
 
419
+ # ====== LOCAL GUARDS (TOP/BOTTOM) — ainda antes de midband ======
420
+ lg_top_drop = lg_bottom_drop = 0
421
+ if bool(local_guard_cfg.get("enabled", True)) and keep.any():
422
+ gray = cv.cvtColor(rgb_u8, cv.COLOR_RGB2GRAY)
423
+ lap = _laplacian_mag(gray)
424
+ top_lg = local_guard_cfg.get("top", {"lap_thr":23.0,"L_rel_q":0.55,"drop_q":0.35,"max_frac":0.35})
425
+ bot_lg = local_guard_cfg.get("bottom", {"lap_thr":22.0,"L_rel_q":0.50,"drop_q":0.30,"max_frac":0.30})
426
+
427
+ # TOP local
428
+ top_w = float(hard_cfg.get("top_deg_width", 22.0))
429
+ top_zone = keep & (_ang_dist_deg(ang, TOP_DEG) <= top_w*0.5)
430
+ if np.any(top_zone):
431
+ L_ref = float(np.nanquantile(L[keep], float(top_lg.get("L_rel_q",0.55)))) if int(keep.sum()) else 0.0
432
+ lap_thr = float(top_lg.get("lap_thr", 23.0))
433
+ if float(np.nanmean(lap[top_zone])) >= lap_thr and float(np.nanmedian(L[top_zone])) <= L_ref:
434
+ thr_low = float(np.nanquantile(L[top_zone], float(top_lg.get("drop_q",0.35))))
435
+ mask_drop = top_zone & (L < thr_low)
436
+ max_frac = float(top_lg.get("max_frac",0.35))
437
+ to_drop = int(mask_drop.sum())
438
+ if to_drop > 0 and to_drop <= int(max_frac * int(keep.sum())) and (int(keep.sum())-to_drop)>=20:
439
+ keep[mask_drop] = False
440
+ lg_top_drop = to_drop
441
+
442
+ # BOTTOM local
443
+ bot_w = float(hard_cfg.get("bottom_deg_width", 18.0))
444
+ bot_zone = keep & (_ang_dist_deg(ang, BOTTOM_DEG) <= bot_w*0.5)
445
+ if np.any(bot_zone):
446
+ L_ref = float(np.nanquantile(L[keep], float(bot_lg.get("L_rel_q",0.50)))) if int(keep.sum()) else 0.0
447
+ lap_thr = float(bot_lg.get("lap_thr", 22.0))
448
+ if float(np.nanmedian(L[bot_zone])) <= L_ref:
449
+ thr_low = float(np.nanquantile(L[bot_zone], float(bot_lg.get("drop_q",0.30))))
450
+ mask_drop = bot_zone & (L < thr_low)
451
+ max_frac = float(bot_lg.get("max_frac",0.30))
452
+ to_drop = int(mask_drop.sum())
453
+ if to_drop > 0 and to_drop <= int(max_frac * int(keep.sum())) and (int(keep.sum())-to_drop)>=20:
454
+ keep[mask_drop] = False
455
+ lg_bottom_drop = to_drop
456
+
457
+ keep_px_after_guard = int(keep.sum())
458
+ if int(keep.sum()) < 20:
459
+ return None
460
 
461
+ # ----- Anti-pupila / Low-L drop -----
462
  anti_pupil_applied = False
463
  lowL_drop_px = 0
464
 
465
  L_keep_all = L[keep]
466
  C_keep_all = np.hypot(a[keep], b[keep]).astype(np.float32)
467
+ C_mean_full = float(np.nanmean(C_keep_all)) if C_keep_all.size else 0.0
468
  L_p50_all = float(np.nanmedian(L_keep_all)) if L_keep_all.size else 0.0
469
 
470
+ if anti_pupil_enabled and (C_mean_full < anti_pupil_c_low_thr or L_p50_all < anti_pupil_L_p50_low):
471
+ rin2 = rin + float(anti_pupil_guard_px) * roi_scale
472
+ if (rout - rin2) < band_min_w:
473
+ rout = min(float(r_abs)*0.98, rin2 + band_min_w)
474
+ keep2 = _ring_mask(h,w, cx, cy, rin2, rout) & keep
475
+ if int(keep2.sum()) >= 20:
476
+ keep = keep2; rin = rin2
477
+ anti_pupil_applied = True
478
 
479
  if lowL_drop_enabled:
480
  L_ring2 = L[keep]
 
491
  if int(keep.sum()) < 20:
492
  return None
493
 
494
+ # ----- Midband / low-C -----
495
+ band_mode = "full"
496
+ L_keep = L[keep]; a_keep = a[keep]; b_keep = b[keep]
497
+ C_keep = np.hypot(a_keep, b_keep).astype(np.float32)
498
+ c_mean_full = float(np.nanmean(C_keep)) if C_keep.size else 0.0
499
+ l_mean_full = float(np.nanmean(L_keep))
500
+ highlight_frac = float(np.mean(mask_high)) if ring.any() else 1.0
501
+ sclera_frac = float(np.mean(mask_scl)) if ring.any() else 1.0
502
+
503
+ need_mid = False
504
+ if int(ring.sum()) < min_ring_px or ((rout - rin) < (band_min_w + 0.5)) or (highlight_frac >= 0.22) or (sclera_frac >= 0.18):
505
+ need_mid = True
506
+ elif (mid_enabled and c_mean_full < mid_thrC):
507
+ need_mid = True
508
+ if need_mid:
509
+ band_mode = "midband"
510
+ midr = 0.5*(rin + rout)
511
+ hw = max(1.0, float(mid_hw_frac) * float(r_abs))
512
+ rin2 = max(rin + mid_guard, midr - hw)
513
+ rout2 = min(rout - mid_guard, midr + hw)
514
+ sub = _ring_mask(h,w, cx, cy, rin2, rout2) & keep
515
+ if int(sub.sum()) >= 20:
516
+ keep = sub
517
+ rin, rout = rin2, rout2
518
+
519
+ if int(keep.sum()) < 20:
520
+ return None
521
+
522
+ # ----- Pre-nudge periférico -----
523
+ pre_sector_nudge_applied = False
524
+ pre_sector_nudge_applied_pct = 0.0
525
+ if pre_nudge_enabled and c_mean_full < pre_nudge_c_low_thr and highlight_frac <= pre_nudge_high_max and sclera_frac <= pre_nudge_sclera_max:
526
+ shift = float(pre_nudge_pct) * float(r_abs)
527
+ rin3 = max(2.0, rin + shift)
528
+ rout3 = min(float(r_abs)*0.98, rout + shift)
529
+ test = _ring_mask(h,w, cx, cy, rin3, rout3) & keep
530
+ if int(test.sum()) >= int(0.75*int(keep.sum())):
531
+ keep = test
532
+ rin, rout = rin3, rout3
533
+ pre_sector_nudge_applied = True
534
+ pre_sector_nudge_applied_pct = pre_nudge_pct
535
 
536
+ if int(keep.sum()) < 20:
537
+ return None
538
+
539
+ # ----- Top-trim / Edge-guard (existentes) -----
540
+ top_trim_tex_applied = False
541
+ edge_guard_applied = False
542
+
543
+ if trim_enabled and keep.any():
544
+ # trim relativo ao TOPO (270°)
545
+ top_sector = keep & (_ang_dist_deg(ang, TOP_DEG) <= float(trim_deg_top))
546
+ if top_sector.any():
547
+ thrT = np.nanquantile(L[keep], 1.0 - float(trim_L_rel_q))
548
+ keep2 = keep.copy()
549
+ keep2[top_sector & (L < thrT)] = False
550
+ if int(keep2.sum()) >= int(0.8*int(keep.sum())) and int(keep2.sum()) >= 20:
551
+ keep = keep2
552
+ top_trim_tex_applied = True
553
+
554
+ if edge_guard_enabled and keep.any():
555
+ lap = _laplacian_mag(cv.cvtColor(roi.astype(np.uint8), cv.COLOR_RGB2GRAY))
556
+ masks, step = _sector_masks(h, w, cx, cy, keep, max(1,sector_count))
557
+ L_ref = float(np.nanquantile(L[keep], edge_guard_L_rel_q))
558
+ drop = np.zeros(len(masks), dtype=bool)
559
+ for i, mk in enumerate(masks):
560
+ if not mk.any():
561
+ continue
562
+ lap_m = float(np.nanmean(lap[mk]))
563
+ if lap_m >= edge_guard_lap_thr:
564
+ if float(np.nanmedian(L[mk])) <= L_ref:
565
+ drop[i] = True
566
+ if drop.any():
567
+ keep2 = keep.copy()
568
+ for i, d in enumerate(drop):
569
+ if d: keep2[masks[i]] = False
570
+ if int(keep2.sum()) >= int(0.75*int(keep.sum())) and int(keep2.sum()) >= min_px_after:
571
+ keep = keep2
572
+ edge_guard_applied = True
573
+
574
+ if int(keep.sum()) < 20:
575
+ return None
576
+
577
+ # ===== Banimento setorial explícito TOP/BOTTOM =====
578
+ selected_arc_deg = 0.0
579
+ final_mode = band_mode
580
+ radial_shift_pct = 0.0
581
+
582
+ masks, step = _sector_masks(h, w, cx, cy, keep, max(1,sector_count))
583
+ ban_top_deg = float(sector_ban_cfg.get("top_deg", 28.0))
584
+ ban_bottom_deg = float(sector_ban_cfg.get("bottom_deg", 24.0))
585
+ banned_any = False
586
+
587
+ if keep.any():
588
+ top_mask = (_ang_dist_deg(ang, TOP_DEG) <= ban_top_deg*0.5) & keep
589
+ if np.any(top_mask):
590
+ keep[top_mask] = False
591
+ banned_any = True
592
+
593
+ bottom_mask = (_ang_dist_deg(ang, BOTTOM_DEG) <= ban_bottom_deg*0.5) & keep
594
+ if np.any(bottom_mask):
595
+ keep[bottom_mask] = False
596
+ banned_any = True
597
+
598
+ if int(keep.sum()) < 20:
599
+ return None
600
+
601
+ # ===== NUDGE RADIAL pós-seleção (DOWN-ONLY) =====
602
+ if bool(nudge_down_only.get("enabled", True)):
603
+ pct_max = float(nudge_down_only.get("pct_max", 0.05))
604
+ # condição igual à antiga, mas só permite deslocar para "baixo" (aumentar rin/rout no sentido radial)
605
+ L_tmp = L[keep]; a_tmp = a[keep]; b_tmp = b[keep]
606
+ C_tmp = np.hypot(a_tmp, b_tmp).astype(np.float32)
607
+ c_mean_sel = float(np.nanmean(C_tmp)) if C_tmp.size else 0.0
608
+ b_med_sel = float(np.nanmedian(b_tmp)) if b_tmp.size else 0.0
609
+ l_mean_sel = float(np.nanmean(L_tmp)) if L_tmp.size else 0.0
610
+
611
+ if (l_mean_sel >= float(cfg.get("iris_radial_nudge_L_min", 46.0))
612
+ and c_mean_sel <= float(cfg.get("iris_radial_nudge_C_max", 6.5))
613
+ and b_med_sel >= float(cfg.get("iris_radial_nudge_b_min", 0.0))):
614
+ shift = pct_max * float(r_abs)
615
+ rin2 = max(2.0, rin + shift)
616
+ rout2 = min(float(r_abs)*0.98, rout + shift)
617
+ ring2 = _ring_mask(h,w, cx, cy, rin2, rout2) & keep
618
+ if int(ring2.sum()) >= min_px_after:
619
+ keep = ring2
620
+ radial_shift_pct = pct_max
621
+ final_mode = (final_mode + "+nudgeDown")
622
+
623
+ if int(keep.sum()) < 20:
624
+ return None
625
+
626
+ # ===== Cool-preserve (não aquecer olhos frios/claros) =====
627
+ cool_preserve_applied = False
628
+ if bool(cool_preserve_cfg.get("enabled", True)) and keep.any():
629
+ L_f = L[keep]; a_f = a[keep]; b_f = b[keep]
630
+ C_f = np.hypot(a_f, b_f).astype(np.float32)
631
+ b_med = float(np.nanmedian(b_f)) if b_f.size else 0.0
632
+ L_med = float(np.nanmedian(L_f)) if L_f.size else 0.0
633
+ C_med = float(np.nanmedian(C_f)) if C_f.size else 0.0
634
+ if (b_med <= float(cool_preserve_cfg.get("b_med_max", 0.0))) or \
635
+ (L_med >= float(cool_preserve_cfg.get("L_min", 46.0)) and C_med <= float(cool_preserve_cfg.get("C_max", 6.8))):
636
+ # Desabilita trims/â mbar nesse caso (não alteramos keep aqui, só sinalizamos)
637
+ cool_preserve_applied = True
638
+ # Nada a excluir adicionalmente — apenas evita heurísticas “quentes” em outros estágios externos
639
+
640
+ # ----- MÉDIA FINAL -----
641
  L_f = L[keep]; a_f = a[keep]; b_f = b[keep]
642
  C_f = np.hypot(a_f, b_f).astype(np.float32)
643
  if C_f.size == 0:
 
664
  "rin": float(rin), "rout": float(rout),
665
  "roi_bbox": [int(x0),int(y0),int(x1),int(y1)],
666
  "overlay_b64": overlay_b64,
667
+ "band_mode": final_mode if final_mode else band_mode,
668
  "band_width_px": float(rout-rin),
669
  "highlight_frac": float(np.mean(mask_high)) if ring.any() else 1.0,
670
  "sclera_frac": float(np.mean(mask_scl)) if ring.any() else 1.0,
671
+ "c_mean_full": float(c_mean_full),
672
+ "l_mean_full": float(l_mean_full),
673
+
674
+ # Ângulos de referência (auto-audit)
675
+ "top_deg_ref": float(TOP_DEG),
676
+ "bottom_deg_ref": float(BOTTOM_DEG),
677
+
678
+ # Auditoria hard/local guards
679
+ "keep_px_before_hard": int(keep_px_before_hard),
680
+ "top_hard_px": int(top_hard_px),
681
+ "bottom_hard_px": int(bottom_hard_px),
682
+ "keep_px_after_hard": int(keep_px_after_hard),
683
+ "top_guard_drop_px": int(lg_top_drop),
684
+ "bottom_guard_drop_px": int(lg_bottom_drop),
685
+ "keep_px_after_guard": int(keep_px_after_guard),
686
+
687
+ # Seleção/setor/nudge
688
+ "selected_arc_deg": float(selected_arc_deg),
689
+ "radial_shift_pct": float(radial_shift_pct),
690
+
691
+ # Flags já existentes
692
+ "anti_pupil_applied": bool(anti_pupil_applied),
693
+ "lowL_drop_px": int(lowL_drop_px),
694
+ "top_trim_tex_applied": bool(top_trim_tex_applied),
695
+ "edge_guard_applied": bool(edge_guard_applied),
696
+
697
+ # Proteção de cor fria
698
+ "cool_preserve_applied": bool(cool_preserve_applied),
699
  }
700
 
701
+ stats = {
702
+ "L": Lm, "a": am, "b": bm, "C": Cm, "area": ring_px_final,
703
+ "L_p50": float(np.nanmedian(L_f)), "C_p50": float(np.nanmedian(C_f))
704
+ }
705
  return {
706
+ "score": float(ring_px_final * (1.0 - float(np.mean(mask_high)) if ring.any() else 1.0) * (1.0 - float(np.mean(mask_scl)) if ring.any() else 1.0)),
707
+ "stats": stats,
 
 
 
708
  "diag": diag
709
  }
710
 
711
+ # 1) círculos do MediaPipe
712
+ candidates = []
713
  if face_result is not None:
714
  for (roi, mroi, bbox) in rois:
715
  for circ in (left_circ, right_circ):
 
720
  cand = _score_one(roi, mroi, bbox, cx_abs, cy_abs, r_abs, tech_label="mp_iris")
721
  if cand is not None: candidates.append(cand)
722
 
723
+ # 2) fallback geométrico
724
  if not candidates:
725
  for (roi, mroi, bbox) in rois:
726
  m = mroi.astype(np.uint8)
 
735
  if cand is not None: candidates.append(cand)
736
 
737
  if not candidates:
738
+ return None, {
739
+ "tech":"mp_iris","gate_reason":"no_valid_ring",
740
+ "ring_px":0,"cx":None,"cy":None,"rin":None,"rout":None,
741
+ "roi_bbox":None,"overlay_b64":None,"band_mode":None,
742
+ "band_width_px":None,"highlight_frac":0.0,"sclera_frac":0.0,
743
+ "c_mean_full":None,"l_mean_full":None,
744
+ "final_mode": None, "selected_arc_deg": 0.0, "radial_shift_pct": 0.0,
745
+ "top_deg_ref": float(TOP_DEG), "bottom_deg_ref": float(BOTTOM_DEG)
746
+ }
747
 
748
  best = max(candidates, key=lambda c: c["score"])
749
  stats = best["stats"].copy()