Source code for iris.nodes.eye_properties_estimation.circle_fit_for_eye_center_method
+from typing import Tuple
+
+import cv2
+import numpy as np
+from pydantic import Field
+
+from iris.io.class_configs import Algorithm
+from iris.io.dataclasses import EyeCenters, GeometryPolygons
+from iris.io.errors import EyeCentersEstimationError
+
+
+
+[docs]
+class CircleFitEyeCenterMethod(Algorithm):
+ """Estimate pupil and iris centers using a robust circle-fitting approach.
+
+ This algorithm estimates the center of a pupil or iris polygon using:
+
+ 1. an initial least-squares circle fit to all polygon points,
+ 2. trimming of outlier points based on radial residuals using a dynamic threshold,
+ 3. a second circle fit on the retained inliers.
+
+ LIMITATIONS:
+ This method assumes that the pupil and iris contours are reasonably well
+ approximated by circles in image space. It is therefore most appropriate
+ when off-gaze and strong perspective distortion have already been filtered out.
+ """
+
+
+[docs]
+ class Parameters(Algorithm.Parameters):
+ """Default parameters for circle-fit eye center algorithm."""
+
+ mad_scale: float = Field(..., gt=0.0)
+
+
+ __parameters_type__ = Parameters
+
+ def __init__(
+ self,
+ mad_scale: float = 3.0,
+ ) -> None:
+ """Assign parameters.
+
+ Args:
+ mad_scale (float, optional): Scale factor used in the dynamic inlier
+ threshold: median(residuals) + mad_scale * MAD(residuals).
+ Defaults to 3.0.
+ """
+ super().__init__(mad_scale=mad_scale)
+
+
+[docs]
+ def run(self, geometries: GeometryPolygons) -> EyeCenters:
+ """Estimate pupil and iris centers.
+
+ Args:
+ geometries (GeometryPolygons): Pupil and iris geometry polygons.
+
+ Returns:
+ EyeCenters: Estimated pupil and iris center coordinates.
+ """
+ pupil_center_x, pupil_center_y = self._calculate_circle_fit_center(geometries.pupil_array.astype(np.float64))
+ iris_center_x, iris_center_y = self._calculate_circle_fit_center(geometries.iris_array.astype(np.float64))
+
+ return EyeCenters(
+ pupil_x=pupil_center_x,
+ pupil_y=pupil_center_y,
+ iris_x=iris_center_x,
+ iris_y=iris_center_y,
+ )
+
+
+ def _calculate_circle_fit_center(self, polygon: np.ndarray) -> Tuple[float, float]:
+ """Estimate the center of a polygon using fit -> trim outliers -> refit.
+
+ Args:
+ polygon (np.ndarray): Polygon points of shape (N, 2) representing a
+ contour that is approximately circular.
+
+ Raises:
+ EyeCentersEstimationError: Raised if the polygon is invalid or if a
+ valid circle cannot be fit.
+
+ Returns:
+ Tuple[float, float]: Estimated center coordinates (x, y).
+ """
+ if polygon.ndim != 2 or polygon.shape[1] != 2 or polygon.shape[0] < 3:
+ raise EyeCentersEstimationError("Polygon must have shape (N, 2) with at least 3 points")
+
+ pts = polygon.astype(np.float64, copy=False)
+
+ # Initial least-squares circle fit
+ x = pts[:, 0]
+ y = pts[:, 1]
+
+ A = np.column_stack([x, y, np.ones_like(x)]).astype(np.float64)
+ b = (-(x * x + y * y)).reshape(-1, 1).astype(np.float64)
+
+ ok, coeffs = cv2.solve(A, b, flags=cv2.DECOMP_SVD)
+ if not ok:
+ raise EyeCentersEstimationError("Circle fit failed")
+
+ a, b_, c = coeffs.ravel()
+
+ cx = -a / 2.0
+ cy = -b_ / 2.0
+
+ r_sq = cx * cx + cy * cy - c
+ if r_sq <= 0:
+ raise EyeCentersEstimationError("Failed to fit a valid circle to the polygon")
+
+ r = np.sqrt(r_sq)
+ center = np.array([cx, cy], dtype=np.float64)
+
+ # Trim outliers using dynamic radial residual threshold
+ d = np.linalg.norm(pts - center, axis=1)
+ residuals = np.abs(d - r)
+
+ median_residual = np.median(residuals)
+ mad = np.median(np.abs(residuals - median_residual))
+
+ if mad == 0:
+ keep_mask = residuals <= median_residual
+ else:
+ threshold = median_residual + self.params.mad_scale * mad
+ keep_mask = residuals <= threshold
+
+ inliers = pts[keep_mask]
+
+ # Ensure enough points remain to refit
+ if len(inliers) < 3:
+ keep_idx = np.argsort(residuals)[:3]
+ inliers = pts[keep_idx]
+
+ # Refit circle on inliers
+ x = inliers[:, 0]
+ y = inliers[:, 1]
+
+ A = np.column_stack([x, y, np.ones_like(x)])
+ b = -(x * x + y * y)
+
+ coeffs, _, _, _ = np.linalg.lstsq(A, b, rcond=None)
+
+ a, b_, c = coeffs
+ cx = -a / 2.0
+ cy = -b_ / 2.0
+
+ r_sq = cx * cx + cy * cy - c
+ if r_sq <= 0:
+ raise EyeCentersEstimationError("Failed to refit a valid circle to the inlier polygon points")
+
+ return float(cx), float(cy)
+
+