| """
|
| 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.")
|
|
|