Spaces:
Running
Running
Update app/eyes_localizer.py
Browse files- 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 |
-
|
| 25 |
"""
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
| 30 |
lab = color.rgb2lab(arr)
|
| 31 |
-
|
| 32 |
-
a = lab[...,1].astype(np.float32)
|
| 33 |
-
b = lab[...,2].astype(np.float32)
|
| 34 |
-
return L, a, b
|
| 35 |
|
| 36 |
-
def
|
| 37 |
-
|
| 38 |
-
|
| 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
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
return
|
| 120 |
-
|
| 121 |
-
def
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
return
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
"""
|
| 129 |
-
Retorna:
|
| 130 |
-
metrics_dict
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
| 135 |
"""
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
return None, {"tech":"mp_iris","gate_reason":"no_eyes_mask"}
|
| 144 |
|
| 145 |
-
# ----
|
| 146 |
-
|
| 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 |
-
|
| 154 |
-
for
|
| 155 |
-
|
| 156 |
-
x0 = max(0,
|
| 157 |
-
x1 = min(W,
|
| 158 |
-
if (x1-x0) <
|
| 159 |
continue
|
| 160 |
-
roi = orig_rgb[y0:y1, x0:x1
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
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 |
-
#
|
| 248 |
-
rin
|
| 249 |
-
rout = min(float(r)*0.98, float(r)*float(outer_fr[1]))
|
| 250 |
-
|
| 251 |
-
|
| 252 |
midr = 0.5*(rin + rout)
|
| 253 |
-
rin
|
| 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
|
| 266 |
|
| 267 |
-
# highlight + sclera (L alto
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
highlight_frac = float(np.mean((L >= L_hi) & ring)) if ring.any() else 1.0
|
| 274 |
-
sclera_frac
|
| 275 |
|
| 276 |
-
|
|
|
|
| 277 |
if int(keep.sum()) < 20:
|
| 278 |
continue
|
| 279 |
|
| 280 |
-
# (opcional) trim setorial
|
| 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 |
-
|
| 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 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
trim_top_deg = float(trim_deg_top)
|
| 297 |
if bot_sector.any():
|
| 298 |
L_bot = L[bot_sector]
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
trim_bot_deg = float(trim_deg_bottom)
|
| 303 |
|
| 304 |
-
|
| 305 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
|
| 307 |
-
#
|
| 308 |
-
|
| 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 |
-
|
|
|
|
| 320 |
band_mode = "midband"
|
| 321 |
-
# sub-anel em torno do raio médio
|
| 322 |
mid_r = 0.5*(rin + rout)
|
| 323 |
-
hw
|
| 324 |
-
rin2
|
| 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 |
-
|
| 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(
|
| 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
|
| 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 |
-
#
|
| 368 |
-
|
| 369 |
-
|
|
|
|
|
|
|
| 370 |
|
| 371 |
-
|
| 372 |
-
|
|
|
|
| 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)
|
| 386 |
-
"
|
| 387 |
-
"area":
|
| 388 |
-
|
| 389 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
})
|
| 408 |
|
| 409 |
if not candidates:
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 416 |
stats = best["stats"].copy()
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 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
|