| """ |
| COMPLETE PATCHED preprocessing_pipeline.py |
| ALL FIXES INTEGRATED: |
| - Anisotropic foreshortening correction in compute_roi_warp() |
| - Updated step1_normalize() to pass head_pose |
| - Fixed bounds check in extract_edge_points() |
| |
| STEP 2 - Region-Aware Illumination Normalization |
| ================================================= |
| Novel Eye Tracking Preprocessing Pipeline | InsightUX |
| |
| Run AFTER step1_geometric_normalization.py. |
| This file imports Step 1 and adds Step 2 on top. |
| """ |
|
|
| import cv2 |
| import numpy as np |
| import mediapipe as mp |
| import argparse |
| import sys |
| from dataclasses import dataclass |
| from typing import Tuple, Optional |
|
|
|
|
| |
| |
| |
|
|
| def create_face_mesh(static_image_mode: bool = False, |
| max_num_faces: int = 1, |
| refine_landmarks: bool = True, |
| min_detection_confidence: float = 0.5, |
| min_tracking_confidence: float = 0.5): |
| """ |
| Creates and returns a MediaPipe FaceMesh instance. |
| refine_landmarks=True is required for iris landmarks (indices 468-477). |
| """ |
| try: |
| from mediapipe.python.solutions.face_mesh import FaceMesh |
| except ImportError as exc: |
| py = sys.version.split()[0] |
| mp_ver = getattr(mp, "__version__", "unknown") |
| raise RuntimeError( |
| f"mediapipe {mp_ver} on Python {py} does not include the legacy " |
| "solutions.face_mesh API. Use Python 3.11 with mediapipe==0.10.18 " |
| "and reinstall: pip install mediapipe==0.10.18" |
| ) from exc |
|
|
| return FaceMesh( |
| static_image_mode=static_image_mode, |
| max_num_faces=max_num_faces, |
| refine_landmarks=refine_landmarks, |
| min_detection_confidence=min_detection_confidence, |
| min_tracking_confidence=min_tracking_confidence, |
| ) |
|
|
|
|
| def compute_iris_radius(landmarks, iris_indices: list, frame_shape: tuple) -> float: |
| """ |
| Computes iris radius in frame pixels from MediaPipe iris landmarks. |
| MediaPipe iris layout: index 0 = center, 1-4 = cardinal rim points. |
| Radius = mean distance from center to the 4 rim points. |
| |
| ALWAYS use this instead of hardcoding iris_radius_frame = 15.0. |
| """ |
| h, w = frame_shape[:2] |
| pts = np.array( |
| [(landmarks[i].x * w, landmarks[i].y * h) for i in iris_indices], |
| dtype=np.float32 |
| ) |
| center = pts[0] |
| radius = float(np.mean(np.linalg.norm(pts[1:] - center, axis=1))) |
| return max(radius, 1.0) |
|
|
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| FACE_3D_MODEL = np.array([ |
| [ 0.0, 0.0, 0.0], |
| [ 0.0, 63.6, 12.5], |
| [-43.3, -32.7, 26.0], |
| [ 43.3, -32.7, 26.0], |
| [-28.9, 28.9, 24.1], |
| [ 28.9, 28.9, 24.1], |
| ], dtype=np.float64) |
|
|
| FACE_2D_INDICES = [1, 152, 33, 263, 61, 291] |
|
|
| LEFT_EYE_INDICES = [33, 7, 163, 144, 145, 153, 154, 155, |
| 133, 173, 157, 158, 159, 160, 161, 246] |
| RIGHT_EYE_INDICES = [362, 382, 381, 380, 374, 373, 390, 249, |
| 263, 466, 388, 387, 386, 385, 384, 398] |
|
|
| LEFT_IRIS_INDICES = [468, 469, 470, 471, 472] |
| RIGHT_IRIS_INDICES = [473, 474, 475, 476, 477] |
|
|
| LEFT_EAR_INDICES = [33, 160, 158, 133, 153, 144] |
| RIGHT_EAR_INDICES = [362, 385, 387, 263, 373, 380] |
|
|
| PATCH_W, PATCH_H = 60, 36 |
| EYE_PAD_FACTOR = 0.45 |
|
|
|
|
| |
| |
| |
|
|
| @dataclass |
| class HeadPose: |
| pitch: float |
| yaw: float |
| roll: float |
| rvec: np.ndarray |
| tvec: np.ndarray |
| reproj_error: float |
|
|
|
|
| @dataclass |
| class NormalizedEyePatch: |
| raw_crop: np.ndarray |
| norm_crop: np.ndarray |
| diff_map: np.ndarray |
| bbox_raw: Tuple[int,int,int,int] |
| warp_M: np.ndarray |
| iris_center: Tuple[float,float] |
| ear: float |
| is_open: bool |
| norm_quality: float |
|
|
|
|
| |
| |
| |
|
|
| def estimate_camera_matrix(frame_shape: tuple) -> np.ndarray: |
| h, w = frame_shape[:2] |
| f = float(w) |
| cx = w / 2.0 |
| cy = h / 2.0 |
| return np.array([ |
| [f, 0., cx], |
| [0., f, cy], |
| [0., 0., 1.], |
| ], dtype=np.float64) |
|
|
|
|
| |
| |
| |
|
|
| def estimate_head_pose( |
| landmarks, |
| frame_shape: tuple, |
| camera_matrix: np.ndarray, |
| ) -> Optional[HeadPose]: |
| h, w = frame_shape[:2] |
| dist = np.zeros((4, 1), dtype=np.float64) |
| pts2d = np.array( |
| [(landmarks[i].x * w, landmarks[i].y * h) for i in FACE_2D_INDICES], |
| dtype=np.float64 |
| ) |
|
|
| ok, rvec, tvec = cv2.solvePnP( |
| FACE_3D_MODEL, pts2d, camera_matrix, dist, |
| flags=cv2.SOLVEPNP_EPNP |
| ) |
| if not ok: |
| return None |
|
|
| rvec, tvec = cv2.solvePnPRefineLM( |
| FACE_3D_MODEL, pts2d, camera_matrix, dist, rvec, tvec |
| ) |
|
|
| proj, _ = cv2.projectPoints(FACE_3D_MODEL, rvec, tvec, camera_matrix, dist) |
| reproj = float(np.mean(np.linalg.norm(proj.reshape(-1,2) - pts2d, axis=1))) |
|
|
| R, _ = cv2.Rodrigues(rvec) |
| pitch, yaw, roll = _R_to_euler(R) |
|
|
| return HeadPose(pitch=pitch, yaw=yaw, roll=roll, |
| rvec=rvec, tvec=tvec, reproj_error=reproj) |
|
|
|
|
| def _R_to_euler(R: np.ndarray) -> Tuple[float, float, float]: |
| """Rotation matrix -> (pitch, yaw, roll) degrees.""" |
| sy = np.sqrt(R[0,0]**2 + R[1,0]**2) |
| if sy > 1e-6: |
| pitch = np.degrees(np.arctan2( R[2,1], R[2,2])) |
| yaw = np.degrees(np.arctan2(-R[2,0], sy)) |
| roll = np.degrees(np.arctan2( R[1,0], R[0,0])) |
| else: |
| pitch = np.degrees(np.arctan2(-R[1,2], R[1,1])) |
| yaw = np.degrees(np.arctan2(-R[2,0], sy)) |
| roll = 0.0 |
| return pitch, yaw, roll |
|
|
|
|
| |
| |
| |
|
|
| def extract_roi( |
| frame: np.ndarray, |
| landmarks, |
| eye_indices: list, |
| pad: float = EYE_PAD_FACTOR, |
| ) -> Tuple[np.ndarray, Tuple[int,int,int,int]]: |
| h, w = frame.shape[:2] |
| pts = np.array( |
| [(landmarks[i].x * w, landmarks[i].y * h) for i in eye_indices], |
| dtype=np.float32 |
| ) |
| xmin, ymin = pts.min(axis=0) |
| xmax, ymax = pts.max(axis=0) |
| ew, eh = xmax - xmin, ymax - ymin |
|
|
| x1 = max(0, int(xmin - ew * pad)) |
| y1 = max(0, int(ymin - eh * pad)) |
| x2 = min(w, int(xmax + ew * pad)) |
| y2 = min(h, int(ymax + eh * pad)) |
|
|
| if x2 <= x1 or y2 <= y1: |
| blank = np.zeros((PATCH_H, PATCH_W), dtype=np.uint8) |
| return blank, (0, 0, PATCH_W, PATCH_H) |
|
|
| roi = frame[y1:y2, x1:x2] |
| if roi.ndim == 3: |
| roi = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) |
| return roi.copy(), (x1, y1, x2, y2) |
|
|
|
|
| |
| |
| |
|
|
| def compute_roi_warp( |
| landmarks, |
| frame_shape: tuple, |
| bbox: Tuple[int,int,int,int], |
| anchor_indices, |
| roll_deg: float, |
| head_pose: 'HeadPose' = None, |
| ) -> Tuple[np.ndarray, Tuple[float,float]]: |
| """ |
| Compute 2D affine warp for eye ROI normalization, with optional |
| anisotropic foreshortening correction. |
| |
| PATCHED: Now includes foreshortening correction via anisotropic pre-scaling. |
| |
| Args: |
| head_pose: optional HeadPose object. If provided, applies anisotropic |
| pre-scaling to approximately undo the eye shape distortion |
| caused by head pitch/yaw. If None, uses the old (roll-only) |
| normalization. |
| |
| Returns: |
| (warp_M, anchor_local): affine warp matrix and anchor point in ROI coords |
| |
| The foreshortening correction works by: |
| - A head pitched down (looking at screen) makes the eye appear taller |
| in the image (Y foreshortened) -> pre-scale X by 1/cos(pitch) |
| - A head turned left (yaw negative) makes the eye appear narrower |
| in the image (X foreshortened) -> pre-scale Y by 1/cos(yaw) |
| |
| Both corrections are applied as pre-scaling before the existing similarity |
| (rotate + translate + uniform scale) warp, so they don't interfere with |
| roll correction. |
| """ |
| h, w = frame_shape[:2] |
| x1, y1, x2, y2 = bbox |
| roi_w = max(x2 - x1, 1) |
| roi_h = max(y2 - y1, 1) |
|
|
| anchor_pts = np.array( |
| [(landmarks[i].x * w, landmarks[i].y * h) for i in anchor_indices], |
| dtype=np.float32 |
| ) |
| anchor_global = anchor_pts.mean(axis=0) |
|
|
| anchor_local = ( |
| float(anchor_global[0] - x1), |
| float(anchor_global[1] - y1), |
| ) |
|
|
| |
| prescale_x = 1.0 |
| prescale_y = 1.0 |
| |
| if head_pose is not None: |
| |
| |
| pitch_rad = np.radians(np.clip(head_pose.pitch, -35.0, 35.0)) |
| prescale_x *= 1.0 / (np.cos(pitch_rad) + 1e-6) |
| |
| |
| |
| yaw_rad = np.radians(np.clip(head_pose.yaw, -35.0, 35.0)) |
| prescale_y *= 1.0 / (np.cos(yaw_rad) + 1e-6) |
| |
| |
| prescale_x = np.clip(prescale_x, 0.8, 1.5) |
| prescale_y = np.clip(prescale_y, 0.8, 1.5) |
| |
|
|
| |
| roi_w_eff = roi_w * prescale_x |
| roi_h_eff = roi_h * prescale_y |
| |
| scale = min(PATCH_W / roi_w_eff, PATCH_H / roi_h_eff) |
|
|
| a = np.radians(-roll_deg) |
| cos_a, sin_a = np.cos(a), np.sin(a) |
|
|
| cx, cy = anchor_local |
| |
| |
| cx_prescaled = cx * prescale_x |
| cy_prescaled = cy * prescale_y |
|
|
| tx = PATCH_W / 2.0 + scale * (-cos_a * cx_prescaled + sin_a * cy_prescaled) |
| ty = PATCH_H / 2.0 + scale * (-sin_a * cx_prescaled - cos_a * cy_prescaled) |
|
|
| warp_M = np.array([ |
| [scale * cos_a, -scale * sin_a, tx], |
| [scale * sin_a, scale * cos_a, ty], |
| ], dtype=np.float64) |
|
|
| return warp_M, anchor_local |
|
|
|
|
| def apply_warp_to_roi( |
| gray_roi: np.ndarray, |
| warp_M: np.ndarray, |
| ) -> np.ndarray: |
| return cv2.warpAffine( |
| gray_roi, warp_M, (PATCH_W, PATCH_H), |
| flags=cv2.INTER_LANCZOS4, |
| borderMode=cv2.BORDER_REPLICATE |
| ) |
|
|
|
|
| def raw_crop_resized(gray_roi: np.ndarray) -> np.ndarray: |
| if gray_roi.size == 0: |
| return np.zeros((PATCH_H, PATCH_W), dtype=np.uint8) |
| return cv2.resize(gray_roi, (PATCH_W, PATCH_H), interpolation=cv2.INTER_LANCZOS4) |
|
|
|
|
| |
| |
| |
|
|
| def iris_center_in_patch( |
| iris_local: Tuple[float,float], |
| warp_M: np.ndarray, |
| ) -> Tuple[float,float]: |
| ix, iy = iris_local |
| px = warp_M[0,0]*ix + warp_M[0,1]*iy + warp_M[0,2] |
| py = warp_M[1,0]*ix + warp_M[1,1]*iy + warp_M[1,2] |
| return float(px), float(py) |
|
|
|
|
| |
| |
| |
|
|
| def compute_norm_quality( |
| raw_crop: np.ndarray, |
| norm_crop: np.ndarray, |
| ) -> float: |
| def entropy(patch: np.ndarray) -> float: |
| gx = cv2.Sobel(patch, cv2.CV_64F, 1, 0, ksize=3) |
| gy = cv2.Sobel(patch, cv2.CV_64F, 0, 1, ksize=3) |
| angles = np.arctan2(gy, gx) |
| hist, _ = np.histogram(angles.ravel(), bins=36, range=(-np.pi, np.pi)) |
| hist = hist.astype(np.float64) + 1e-9 |
| hist /= hist.sum() |
| return float(-np.sum(hist * np.log(hist))) |
|
|
| H_raw = entropy(raw_crop) |
| H_norm = entropy(norm_crop) |
| if H_raw < 1e-9: |
| return 0.0 |
| return float(np.clip((H_raw - H_norm) / H_raw, 0.0, 1.0)) |
|
|
|
|
| |
| |
| |
|
|
| def compute_ear(landmarks, ear_indices: list, frame_shape: tuple) -> float: |
| h, w = frame_shape[:2] |
| pts = np.array( |
| [(landmarks[i].x * w, landmarks[i].y * h) for i in ear_indices], |
| dtype=np.float32 |
| ) |
| A = np.linalg.norm(pts[1] - pts[5]) |
| B = np.linalg.norm(pts[2] - pts[4]) |
| C = np.linalg.norm(pts[0] - pts[3]) |
| return float((A + B) / (2.0 * C)) if C > 1e-6 else 0.0 |
|
|
|
|
| |
| |
| |
|
|
| def step1_normalize( |
| frame: np.ndarray, |
| landmarks, |
| head_pose: HeadPose, |
| eye_indices: list, |
| ear_indices: list, |
| iris_indices: list, |
| ear_threshold: float = 0.20, |
| ) -> NormalizedEyePatch: |
| """ |
| PATCHED: Now passes full head_pose to compute_roi_warp for foreshortening correction. |
| """ |
| ear = compute_ear(landmarks, ear_indices, frame.shape) |
| is_open = ear >= ear_threshold |
|
|
| gray_roi, bbox = extract_roi(frame, landmarks, eye_indices) |
|
|
| |
| |
| |
| |
| |
| |
| |
| canthus_indices = (ear_indices[0], ear_indices[3]) |
| warp_M, _anchor_local = compute_roi_warp( |
| landmarks, frame.shape, bbox, canthus_indices, head_pose.roll, |
| head_pose=head_pose |
| ) |
|
|
| norm_crop = apply_warp_to_roi(gray_roi, warp_M) |
| raw_crop = raw_crop_resized(gray_roi) |
|
|
| diff = cv2.absdiff(raw_crop, norm_crop) |
| diff_vis = cv2.applyColorMap( |
| cv2.convertScaleAbs(diff, alpha=4.0), cv2.COLORMAP_INFERNO |
| ) |
|
|
| |
| |
| |
| |
| h, w = frame.shape[:2] |
| iris_pts = np.array( |
| [(landmarks[i].x * w, landmarks[i].y * h) for i in iris_indices], |
| dtype=np.float32 |
| ) |
| iris_global = iris_pts[0] |
| x1, y1, _, _ = bbox |
| iris_local = (float(iris_global[0] - x1), float(iris_global[1] - y1)) |
| iris_patch = iris_center_in_patch(iris_local, warp_M) |
|
|
| quality = compute_norm_quality(raw_crop, norm_crop) if is_open else 0.0 |
|
|
| return NormalizedEyePatch( |
| raw_crop=raw_crop, |
| norm_crop=norm_crop, |
| diff_map=diff_vis, |
| bbox_raw=bbox, |
| warp_M=warp_M, |
| iris_center=iris_patch, |
| ear=ear, |
| is_open=is_open, |
| norm_quality=quality, |
| ) |
|
|
|
|
| |
| |
| |
|
|
| def draw_pose_axes( |
| frame: np.ndarray, |
| head_pose: HeadPose, |
| camera_matrix: np.ndarray, |
| nose_lm, |
| frame_shape: tuple, |
| length: float = 50.0, |
| ) -> None: |
| h, w = frame_shape[:2] |
| dist = np.zeros((4,1)) |
| ax3d = np.float32([[length,0,0],[0,length,0],[0,0,length]]) |
| pts,_ = cv2.projectPoints(ax3d, head_pose.rvec, head_pose.tvec, |
| camera_matrix, dist) |
| pts = pts.reshape(-1,2).astype(int) |
| orig = (int(nose_lm.x * w), int(nose_lm.y * h)) |
| cv2.arrowedLine(frame, orig, tuple(pts[0]), (0,0,220), 2, tipLength=0.2) |
| cv2.arrowedLine(frame, orig, tuple(pts[1]), (0,220,0), 2, tipLength=0.2) |
| cv2.arrowedLine(frame, orig, tuple(pts[2]), (220,100,0), 2, tipLength=0.2) |
|
|
|
|
| def draw_main_view( |
| frame: np.ndarray, |
| left: NormalizedEyePatch, |
| right: NormalizedEyePatch, |
| head_pose: HeadPose, |
| camera_matrix: np.ndarray, |
| landmarks, |
| frame_shape: tuple, |
| ) -> np.ndarray: |
| out = frame.copy() |
|
|
| for patch, lbl in [(left,"L"), (right,"R")]: |
| x1,y1,x2,y2 = patch.bbox_raw |
| col = (0,255,0) if patch.is_open else (0,0,255) |
| cv2.rectangle(out,(x1,y1),(x2,y2),col,1) |
| cv2.putText(out, f"{lbl} EAR:{patch.ear:.2f} Q:{patch.norm_quality:.3f}", |
| (x1, max(0,y1-6)), cv2.FONT_HERSHEY_SIMPLEX, 0.38, col, 1) |
|
|
| draw_pose_axes(out, head_pose, camera_matrix, landmarks[1], frame_shape) |
|
|
| lines = [ |
| f"Pitch:{head_pose.pitch:+.1f} Yaw:{head_pose.yaw:+.1f} Roll:{head_pose.roll:+.1f}", |
| f"Reproj err: {head_pose.reproj_error:.1f}px", |
| ] |
| for i, ln in enumerate(lines): |
| cv2.putText(out, ln, (10, 20+i*18), |
| cv2.FONT_HERSHEY_SIMPLEX, 0.45, (200,230,255), 1) |
| return out |
|
|
|
|
| |
| |
| |
| CLAHE_IRIS = cv2.createCLAHE(clipLimit=1.5, tileGridSize=(2, 2)) |
| CLAHE_SCLERA = cv2.createCLAHE(clipLimit=3.5, tileGridSize=(4, 4)) |
| CLAHE_GLOBAL = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(4, 4)) |
|
|
|
|
| |
| |
| |
|
|
| @dataclass |
| class IlluminationResult: |
| step1: NormalizedEyePatch |
| iris_mask: np.ndarray |
| sclera_mask: np.ndarray |
| iris_clahe: np.ndarray |
| sclera_clahe: np.ndarray |
| blended: np.ndarray |
| global_clahe: np.ndarray |
| diff_vs_global: np.ndarray |
| diff_vs_raw: np.ndarray |
| itv: float |
| photo_quality: float |
| is_usable: bool |
|
|
|
|
| ITV_THRESHOLD = 0.25 |
|
|
|
|
| |
| |
| |
|
|
| def adaptive_glint_threshold(patch: np.ndarray) -> int: |
| high_vals = patch[patch > np.percentile(patch, 95)] |
| if len(high_vals) < 10: |
| return 240 |
|
|
| bright_norm = ((high_vals - high_vals.min()) / |
| (high_vals.ptp() + 1e-6) * 255).astype(np.uint8) |
| thresh, _ = cv2.threshold( |
| bright_norm, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU |
| ) |
| adapted = int(high_vals.min() + thresh / 255.0 * high_vals.ptp()) |
| return max(200, min(adapted, 254)) |
|
|
|
|
| def remove_glints_adaptive(patch: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: |
| thresh = adaptive_glint_threshold(patch) |
| _, glint_mask = cv2.threshold(patch, thresh, 255, cv2.THRESH_BINARY) |
|
|
| if cv2.countNonZero(glint_mask) == 0: |
| return patch.copy(), glint_mask |
|
|
| kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) |
| glint_mask = cv2.dilate(glint_mask, kernel, iterations=1) |
| cleaned = cv2.inpaint(patch, glint_mask, inpaintRadius=3, |
| flags=cv2.INPAINT_TELEA) |
| return cleaned, glint_mask |
|
|
|
|
| |
| |
| |
|
|
| def build_iris_mask( |
| iris_center_patch: Tuple[float, float], |
| iris_radius_patch: float, |
| patch_shape: Tuple[int, int], |
| feather_width: float = 0.35, |
| ) -> np.ndarray: |
| H, W = patch_shape |
| cx, cy = iris_center_patch |
| r_iris = max(iris_radius_patch, 1.0) |
|
|
| ys, xs = np.mgrid[0:H, 0:W] |
| dist = np.sqrt((xs - cx)**2 + (ys - cy)**2).astype(np.float32) |
|
|
| r_inner = r_iris * (1.0 - feather_width) |
| r_outer = r_iris |
|
|
| mask = np.zeros((H, W), dtype=np.float32) |
| mask[dist <= r_inner] = 1.0 |
|
|
| feather_zone = (dist > r_inner) & (dist <= r_outer) |
| if feather_zone.any(): |
| t = (dist[feather_zone] - r_inner) / (r_outer - r_inner + 1e-6) |
| mask[feather_zone] = np.cos(0.5 * np.pi * t) ** 2 |
|
|
| return mask |
|
|
|
|
| def estimate_iris_radius_in_patch( |
| iris_radius_frame: float, |
| warp_M: np.ndarray, |
| ) -> float: |
| scale = float(np.linalg.norm(warp_M[0, :2])) |
| return iris_radius_frame * scale |
|
|
|
|
| |
| |
| |
|
|
| def region_aware_clahe( |
| patch: np.ndarray, |
| iris_mask: np.ndarray, |
| ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: |
| iris_enhanced = CLAHE_IRIS.apply(patch) |
| sclera_enhanced = CLAHE_SCLERA.apply(patch) |
|
|
| alpha = iris_mask[:, :, np.newaxis] if patch.ndim == 3 else iris_mask |
| blended = ( |
| alpha * iris_enhanced.astype(np.float32) + |
| (1 - alpha) * sclera_enhanced.astype(np.float32) |
| ).clip(0, 255).astype(np.uint8) |
|
|
| blended = cv2.bilateralFilter(blended, d=5, sigmaColor=20, sigmaSpace=3) |
|
|
| return iris_enhanced, sclera_enhanced, blended |
|
|
|
|
| |
| |
| |
|
|
| def iris_texture_visibility( |
| blended: np.ndarray, |
| iris_mask: np.ndarray, |
| ) -> float: |
| log_response = cv2.Laplacian(blended, cv2.CV_64F, ksize=3) |
|
|
| mask_sum = iris_mask.sum() + 1e-6 |
| weighted_mean = float((log_response * iris_mask).sum() / mask_sum) |
| weighted_var = float( |
| ((log_response - weighted_mean)**2 * iris_mask).sum() / mask_sum |
| ) |
|
|
| itv = 1.0 / (1.0 + np.exp(-(weighted_var - 80.0) / 30.0)) |
| return float(np.clip(itv, 0.0, 1.0)) |
|
|
|
|
| def photometric_quality( |
| blended: np.ndarray, |
| iris_mask: np.ndarray, |
| glint_mask: np.ndarray, |
| ) -> float: |
| itv = iris_texture_visibility(blended, iris_mask) |
|
|
| iris_area = float(iris_mask.sum()) + 1e-6 |
| glint_in_iris = float(((glint_mask > 0).astype(np.float32) * iris_mask).sum()) |
| glint_penalty = np.clip(glint_in_iris / iris_area, 0.0, 1.0) |
|
|
| mean_bright = float(blended.mean()) |
| exposure_score = 1.0 - abs(mean_bright - 120.0) / 120.0 |
| exposure_score = float(np.clip(exposure_score, 0.0, 1.0)) |
|
|
| quality = 0.6 * itv + 0.2 * (1 - glint_penalty) + 0.2 * exposure_score |
| return float(np.clip(quality, 0.0, 1.0)) |
|
|
|
|
| |
| |
| |
|
|
| def step2_illumination( |
| step1_result: NormalizedEyePatch, |
| iris_radius_frame: float, |
| ) -> IlluminationResult: |
| patch_in = step1_result.norm_crop |
|
|
| deglinted, glint_mask = remove_glints_adaptive(patch_in) |
|
|
| iris_r_patch = estimate_iris_radius_in_patch( |
| iris_radius_frame, step1_result.warp_M |
| ) |
| iris_mask = build_iris_mask( |
| iris_center_patch=step1_result.iris_center, |
| iris_radius_patch=iris_r_patch, |
| patch_shape=(PATCH_H, PATCH_W), |
| feather_width=0.35, |
| ) |
| sclera_mask = 1.0 - iris_mask |
|
|
| iris_clahe, sclera_clahe, blended = region_aware_clahe(deglinted, iris_mask) |
|
|
| global_clahe = CLAHE_GLOBAL.apply(deglinted) |
|
|
| itv = iris_texture_visibility(blended, iris_mask) |
| photo_q = photometric_quality(blended, iris_mask, glint_mask) |
| is_usable = itv > ITV_THRESHOLD and step1_result.is_open |
|
|
| diff_vs_global = cv2.applyColorMap( |
| cv2.convertScaleAbs(cv2.absdiff(blended, global_clahe), alpha=4.0), |
| cv2.COLORMAP_PLASMA |
| ) |
| diff_vs_raw = cv2.applyColorMap( |
| cv2.convertScaleAbs(cv2.absdiff(blended, patch_in), alpha=4.0), |
| cv2.COLORMAP_INFERNO |
| ) |
|
|
| return IlluminationResult( |
| step1=step1_result, |
| iris_mask=iris_mask, |
| sclera_mask=sclera_mask, |
| iris_clahe=iris_clahe, |
| sclera_clahe=sclera_clahe, |
| blended=blended, |
| global_clahe=global_clahe, |
| diff_vs_global=diff_vs_global, |
| diff_vs_raw=diff_vs_raw, |
| itv=itv, |
| photo_quality=photo_q, |
| is_usable=is_usable, |
| ) |
|
|
|
|
| |
| |
| |
|
|
| from dataclasses import field |
| from typing import List |
|
|
| NUM_RAYS = 36 |
| RAY_SEARCH_BAND = 0.45 |
| MIN_VALID_RAYS = 10 |
| RANSAC_ITERS = 120 |
| RANSAC_THRESH = 1.8 |
|
|
|
|
| @dataclass |
| class LimbusResult: |
| step2: "IlluminationResult" |
| ellipse: Optional[tuple] |
| edge_points: np.ndarray |
| ray_valid: np.ndarray |
| inlier_mask: np.ndarray |
| limbus_center: Optional[Tuple[float,float]] |
| limbus_axes: Optional[Tuple[float,float]] |
| limbus_angle: float |
| eccentricity: float |
| pupil_center: Optional[Tuple[float,float]] |
| pupil_radius: float |
| angular_coverage: float |
| fit_residual: float |
| limbus_quality: float |
| is_reliable: bool |
|
|
|
|
| def sample_radial_rays( |
| patch: np.ndarray, |
| center: Tuple[float, float], |
| seed_radius: float, |
| n_rays: int = NUM_RAYS, |
| band: float = RAY_SEARCH_BAND, |
| ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: |
| H, W = patch.shape[:2] |
| cx, cy = center |
| r_min = max(1.0, seed_radius * (1.0 - band)) |
| r_max = min(seed_radius * (1.0 + band), |
| min(cx, cy, W - cx, H - cy) - 1) |
| r_max = max(r_max, r_min + 2.0) |
|
|
| n_samples = max(12, int((r_max - r_min) * 3)) |
| r_coords = np.linspace(r_min, r_max, n_samples, dtype=np.float32) |
| angles = np.linspace(0, 2 * np.pi, n_rays, endpoint=False, |
| dtype=np.float32) |
|
|
| profiles = np.zeros((n_rays, n_samples), dtype=np.float32) |
|
|
| for i, theta in enumerate(angles): |
| cos_t = np.cos(theta) |
| sin_t = np.sin(theta) |
| xs = np.clip(cx + r_coords * cos_t, 0, W - 1).astype(np.float32) |
| ys = np.clip(cy + r_coords * sin_t, 0, H - 1).astype(np.float32) |
| profiles[i] = cv2.remap( |
| patch.astype(np.float32), |
| xs.reshape(1, -1), ys.reshape(1, -1), |
| cv2.INTER_LINEAR |
| ).ravel() |
|
|
| return profiles, r_coords, angles |
|
|
|
|
| def find_limbus_edge_on_ray( |
| profile: np.ndarray, |
| r_coords: np.ndarray, |
| expected_r: float, |
| ) -> Tuple[Optional[float], float]: |
| if len(profile) < 4: |
| return None, 0.0 |
|
|
| profile_s = cv2.GaussianBlur( |
| profile.reshape(1, -1).astype(np.float32), |
| (1, 5), sigmaX=1.0 |
| ).ravel() |
|
|
| grad = np.gradient(profile_s.astype(np.float64)) |
|
|
| best_idx = int(np.argmax(grad)) |
| strength = float(grad[best_idx]) |
|
|
| if strength < 2.0: |
| return None, 0.0 |
|
|
| edge_r = float(r_coords[best_idx]) |
| return edge_r, strength |
|
|
|
|
| def extract_edge_points( |
| profiles: np.ndarray, |
| r_coords: np.ndarray, |
| angles: np.ndarray, |
| center: Tuple[float, float], |
| seed_radius: float, |
| ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: |
| """ |
| PATCHED: Fixed bounds check to use PATCH_W and PATCH_H instead of profiles.shape[0] |
| """ |
| cx, cy = center |
| edge_pts = [] |
| strengths = [] |
| ray_valid = np.zeros(len(angles), dtype=bool) |
|
|
| for i, (theta, profile) in enumerate(zip(angles, profiles)): |
| edge_r, strength = find_limbus_edge_on_ray(profile, r_coords, seed_radius) |
| if edge_r is not None: |
| x = cx + edge_r * np.cos(theta) |
| y = cy + edge_r * np.sin(theta) |
| |
| if 0 <= x < PATCH_W and 0 <= y < PATCH_H: |
| edge_pts.append([x, y]) |
| strengths.append(strength) |
| ray_valid[i] = True |
|
|
| if len(edge_pts) == 0: |
| return np.zeros((0, 2), dtype=np.float32), ray_valid, np.zeros(0) |
|
|
| return (np.array(edge_pts, dtype=np.float32), |
| ray_valid, |
| np.array(strengths, dtype=np.float32)) |
|
|
|
|
| def fit_ellipse_ransac( |
| points: np.ndarray, |
| n_iters: int = RANSAC_ITERS, |
| thresh: float = RANSAC_THRESH, |
| ) -> Tuple[Optional[tuple], np.ndarray]: |
| N = len(points) |
| if N < 6: |
| return None, np.zeros(N, dtype=bool) |
|
|
| if N < 10: |
| try: |
| ell = cv2.fitEllipse(points) |
| return ell, np.ones(N, dtype=bool) |
| except cv2.error: |
| return None, np.zeros(N, dtype=bool) |
|
|
| best_ellipse = None |
| best_inliers = np.zeros(N, dtype=bool) |
| best_n_inliers = 0 |
|
|
| rng = np.random.default_rng(42) |
|
|
| for _ in range(n_iters): |
| idx = rng.choice(N, 5, replace=False) |
| sample = points[idx] |
|
|
| try: |
| ell = cv2.fitEllipse(sample) |
| except cv2.error: |
| continue |
|
|
| (cx, cy), (ma, mi), angle = ell |
| if ma < 1 or mi < 1 or ma > 200 or mi > 200: |
| continue |
| if ma / (mi + 1e-6) > 4.0: |
| continue |
|
|
| inliers = _ellipse_inliers(points, ell, thresh) |
| n_in = int(inliers.sum()) |
|
|
| if n_in > best_n_inliers: |
| best_n_inliers = n_in |
| best_inliers = inliers |
| best_ellipse = ell |
|
|
| if best_ellipse is not None and best_n_inliers >= 5: |
| try: |
| best_ellipse = cv2.fitEllipse(points[best_inliers]) |
| except cv2.error: |
| pass |
|
|
| return best_ellipse, best_inliers |
|
|
|
|
| def _ellipse_inliers( |
| points: np.ndarray, |
| ellipse: tuple, |
| thresh: float, |
| ) -> np.ndarray: |
| (cx, cy), (ma, mi), angle_deg = ellipse |
| a = np.radians(angle_deg) |
| cos_a, sin_a = np.cos(a), np.sin(a) |
|
|
| dx = points[:, 0] - cx |
| dy = points[:, 1] - cy |
|
|
| u = dx * cos_a + dy * sin_a |
| v = -dx * sin_a + dy * cos_a |
|
|
| ra = ma / 2.0 + 1e-6 |
| rb = mi / 2.0 + 1e-6 |
|
|
| f = (u / ra)**2 + (v / rb)**2 |
| dist = np.abs(f - 1.0) * (ra * rb) / (ra + rb) |
|
|
| return dist < thresh |
|
|
|
|
| def estimate_pupil_seed( |
| patch: np.ndarray, |
| limbus_ellipse: Optional[tuple], |
| iris_center: Tuple[float, float], |
| iris_radius: float, |
| ) -> Tuple[Optional[Tuple[float,float]], float]: |
| H, W = patch.shape[:2] |
|
|
| mask = np.zeros((H, W), dtype=np.uint8) |
| if limbus_ellipse is not None: |
| cv2.ellipse(mask, limbus_ellipse, 255, -1) |
| else: |
| cv2.circle(mask, (int(iris_center[0]), int(iris_center[1])), |
| int(iris_radius * 0.9), 255, -1) |
|
|
| if cv2.countNonZero(mask) < 5: |
| return None, 0.0 |
|
|
| masked = cv2.bitwise_and(patch, patch, mask=mask) |
|
|
| iris_pixels = patch[mask > 0] |
| if len(iris_pixels) < 5: |
| return None, 0.0 |
|
|
| dark_thresh = float(np.percentile(iris_pixels, 25)) |
| _, dark_mask = cv2.threshold(masked, int(dark_thresh), 255, |
| cv2.THRESH_BINARY_INV) |
| dark_mask = cv2.bitwise_and(dark_mask, mask) |
|
|
| k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) |
| dark_mask = cv2.morphologyEx(dark_mask, cv2.MORPH_OPEN, k, iterations=1) |
|
|
| contours, _ = cv2.findContours(dark_mask, cv2.RETR_EXTERNAL, |
| cv2.CHAIN_APPROX_SIMPLE) |
| if not contours: |
| return None, 0.0 |
|
|
| best = max(contours, key=cv2.contourArea) |
| area = cv2.contourArea(best) |
| if area < 4: |
| return None, 0.0 |
|
|
| M = cv2.moments(best) |
| if M["m00"] < 1e-6: |
| return None, 0.0 |
|
|
| pcx = M["m10"] / M["m00"] |
| pcy = M["m01"] / M["m00"] |
| pradius = float(np.sqrt(area / np.pi)) |
|
|
| return (float(pcx), float(pcy)), pradius |
|
|
|
|
| def compute_limbus_quality( |
| ellipse: Optional[tuple], |
| inlier_mask: np.ndarray, |
| edge_points: np.ndarray, |
| ray_valid: np.ndarray, |
| iris_seed_radius: float, |
| ) -> Tuple[float, float, float]: |
| angular_coverage = float(ray_valid.sum()) / max(len(ray_valid), 1) |
|
|
| if ellipse is None or inlier_mask.sum() < 5: |
| return angular_coverage, 999.0, 0.0 |
|
|
| inlier_pts = edge_points[inlier_mask] |
| dists = _point_to_ellipse_dist(inlier_pts, ellipse) |
| fit_resid = float(np.mean(dists)) if len(dists) > 0 else 999.0 |
|
|
| (cx, cy), (ma, mi), _ = ellipse |
| ratio = min(ma, mi) / (max(ma, mi) + 1e-6) |
| size_ok = 0.5 < (ma / 2) / (iris_seed_radius + 1e-6) < 2.0 |
|
|
| coverage_score = angular_coverage |
| residual_score = float(np.clip(1.0 - fit_resid / 5.0, 0.0, 1.0)) |
| shape_score = ratio * (1.0 if size_ok else 0.3) |
|
|
| quality = 0.4 * coverage_score + 0.4 * residual_score + 0.2 * shape_score |
| return angular_coverage, fit_resid, float(np.clip(quality, 0.0, 1.0)) |
|
|
|
|
| def _point_to_ellipse_dist( |
| points: np.ndarray, |
| ellipse: tuple, |
| ) -> np.ndarray: |
| if len(points) == 0: |
| return np.zeros(0) |
| (cx, cy), (ma, mi), angle_deg = ellipse |
| a = np.radians(angle_deg) |
| ca, sa = np.cos(a), np.sin(a) |
| dx, dy = points[:,0] - cx, points[:,1] - cy |
| u = dx*ca + dy*sa |
| v = -dx*sa + dy*ca |
| ra, rb = ma/2.0 + 1e-6, mi/2.0 + 1e-6 |
| f = (u/ra)**2 + (v/rb)**2 |
| return np.abs(f - 1.0) * (ra*rb)/(ra+rb) |
|
|
|
|
| def step3_limbus( |
| step2_result: "IlluminationResult", |
| iris_radius_frame: float, |
| ) -> LimbusResult: |
| patch = step2_result.blended |
| iris_c = step2_result.step1.iris_center |
| warp_M = step2_result.step1.warp_M |
|
|
| scale = float(np.linalg.norm(warp_M[0, :2])) |
| iris_r_px = float(np.clip(iris_radius_frame * scale, 3.0, min(PATCH_W, PATCH_H) / 2.0 - 1)) |
|
|
| profiles, r_coords, angles = sample_radial_rays( |
| patch, iris_c, iris_r_px, |
| n_rays=NUM_RAYS, band=RAY_SEARCH_BAND |
| ) |
|
|
| edge_pts, ray_valid, strengths = extract_edge_points( |
| profiles, r_coords, angles, iris_c, iris_r_px |
| ) |
|
|
| if len(edge_pts) >= MIN_VALID_RAYS: |
| ellipse, inlier_mask = fit_ellipse_ransac(edge_pts) |
| else: |
| ellipse = None |
| inlier_mask = np.zeros(len(edge_pts), dtype=bool) |
|
|
| if ellipse is None: |
| cx, cy = iris_c |
| ellipse = ( |
| (float(cx), float(cy)), |
| (float(iris_r_px * 2), float(iris_r_px * 2)), |
| 0.0 |
| ) |
| inlier_mask = np.ones(len(edge_pts), dtype=bool) |
|
|
| (ecx, ecy), (ma, mi), eangle = ellipse |
| limbus_center = (float(ecx), float(ecy)) |
| limbus_axes = (float(ma / 2.0), float(mi / 2.0)) |
| ecc = float(np.sqrt(max(0, 1.0 - (min(ma,mi)/max(ma,mi,1e-6))**2))) |
|
|
| pupil_center, pupil_radius = estimate_pupil_seed( |
| patch, ellipse, iris_c, iris_r_px |
| ) |
|
|
| angular_cov, fit_resid, lq = compute_limbus_quality( |
| ellipse, inlier_mask, edge_pts, ray_valid, iris_r_px |
| ) |
| is_reliable = lq > 0.35 and angular_cov > 0.4 |
|
|
| return LimbusResult( |
| step2=step2_result, |
| ellipse=ellipse, |
| edge_points=edge_pts, |
| ray_valid=ray_valid, |
| inlier_mask=inlier_mask, |
| limbus_center=limbus_center, |
| limbus_axes=limbus_axes, |
| limbus_angle=float(eangle), |
| eccentricity=ecc, |
| pupil_center=pupil_center, |
| pupil_radius=pupil_radius, |
| angular_coverage=angular_cov, |
| fit_residual=fit_resid, |
| limbus_quality=lq, |
| is_reliable=is_reliable, |
| ) |
|
|
|
|
| if __name__ == "__main__": |
| import argparse |
| print("preprocessing_pipeline.py loaded OK - this module is meant to be imported,") |
| print("not run directly, by calibrate.py / run_session.py / main_webcam_pipeline.py.") |
|
|