Source code for iris.nodes.geometry_estimation.fusion_extrapolation

from typing import List

import numpy as np

from iris.callbacks.callback_interface import Callback
from iris.io.class_configs import Algorithm
from iris.io.dataclasses import EyeCenters, GeometryPolygons
from iris.nodes.geometry_estimation.linear_extrapolation import LinearExtrapolation
from iris.nodes.geometry_estimation.lsq_ellipse_fit_with_refinement import LSQEllipseFitWithRefinement
from iris.utils.math import cartesian2polar


[docs] class FusionExtrapolation(Algorithm): """Fuse two extrapolation strategies and pick the result based on shape statistics. 1) Circle-like extrapolation: LinearExtrapolation (linear extrapolation algorithm) 2) Ellipse-like extrapolation: LSQEllipseFitWithRefinement (least square ellipse fit with iris polygon refinement) By default, the linear extrapolation is used. If the (relative) spread of radii exceeds a threshold and the ellipse-based result looks sufficiently "regular" (based on squared radii ratios), we prefer the ellipse fit. Notes: - "Relative std" means std/mean with an epsilon guard. - We compare regularity via the relative std of iris/pupil squared-radius ratios under three centering choices (circle/circle, ellipse/ellipse, ellipse/circle). """
[docs] class Parameters(Algorithm.Parameters): """Parameters of fusion extrapolation algorithm.""" circle_extrapolation: Algorithm ellipse_fit: Algorithm algorithm_switch_std_threshold: float algorithm_switch_std_conditioned_multiplier: float
__parameters_type__ = Parameters def __init__( self, circle_extrapolation: Algorithm = LinearExtrapolation(dphi=360 / 512), ellipse_fit: Algorithm = LSQEllipseFitWithRefinement(dphi=360 / 512), algorithm_switch_std_threshold: float = 0.014, algorithm_switch_std_conditioned_multiplier: float = 2.0, callbacks: List[Callback] = [], ) -> None: """Assign parameters. Args: circle_extrapolation (Algorithm, optional): More circular shape estimation algorithm. Defaults to LinearExtrapolation(dphi=360 / 512, degrees / width of normalized image). ellipse_fit (Algorithm, optional): More elliptical shape estimation algorithm. Defaults to LSQEllipseFitWithRefinement(dphi=360 / 512, degrees / width of normalized image). algorithm_switch_std_threshold (float, optional): Threshold on (rel. std of iris + rel. std of pupil) beyond which we consider switching to ellipse.. Defaults to 0.014. algorithm_switch_std_conditioned_multiplier (float, optional): How strongly iris spread penalizes circle regularity in the switch condition. Default: 2.0. callbacks (List[Callback], optional): _description_. Defaults to []. """ super().__init__( ellipse_fit=ellipse_fit, circle_extrapolation=circle_extrapolation, algorithm_switch_std_threshold=algorithm_switch_std_threshold, algorithm_switch_std_conditioned_multiplier=algorithm_switch_std_conditioned_multiplier, callbacks=callbacks, ) @staticmethod def _relative_std(x: np.ndarray, eps: float = 1e-12) -> float: """ Relative std = std / max(mean, eps). Args: x: 1D array-like. eps: Small guard to avoid division by zero. Returns: float: relative std. """ x = np.asarray(x) m = float(np.mean(x)) s = float(np.std(x)) return s / max(abs(m), eps) @staticmethod def _squared_relative_radii( iris_centered: np.ndarray, pupil_centered: np.ndarray, eps: float = 1e-12 ) -> np.ndarray: """ Ratio of iris over pupil squared radii, element wise. Args: iris_centered: (N, 2) centered iris points. pupil_centered: (N, 2) centered pupil points. eps: Small guard to avoid division by zero. Returns: (N,) array: iris_sq / pupil_sq """ iris_sq = np.sum(iris_centered**2, axis=1) pupil_sq = np.sum(pupil_centered**2, axis=1) return np.divide(iris_sq, np.maximum(pupil_sq, eps))
[docs] def run(self, input_polygons: GeometryPolygons, eye_center: EyeCenters) -> GeometryPolygons: """Perform extrapolation algorithm and select the most plausible result. Args: input_polygons (GeometryPolygons): Smoothed polygons. eye_center (EyeCenters): Computed eye centers. Returns: GeometryPolygons: Extrapolated polygons """ xs_iris, ys_iris = input_polygons.iris_array[:, 0], input_polygons.iris_array[:, 1] rhos_iris, _ = cartesian2polar(xs_iris, ys_iris, eye_center.iris_x, eye_center.iris_y) xs_pupil, ys_pupil = input_polygons.pupil_array[:, 0], input_polygons.pupil_array[:, 1] rhos_pupil, _ = cartesian2polar(xs_pupil, ys_pupil, eye_center.pupil_x, eye_center.pupil_y) circle_poly = self.params.circle_extrapolation(input_polygons, eye_center) ellipse_poly = self.params.ellipse_fit(input_polygons) circle_iris = circle_poly.iris_array circle_pupil = circle_poly.pupil_array ellipse_iris = ellipse_poly.iris_array ellipse_pupil = ellipse_poly.pupil_array circle_center = np.mean(circle_pupil, axis=0) ellipse_center = np.mean(ellipse_pupil, axis=0) circle_iris_centered = circle_iris - circle_center circle_pupil_centered = circle_pupil - circle_center ellipse_iris_centered = ellipse_iris - ellipse_center ellipse_pupil_centered = ellipse_pupil - ellipse_center ec_iris_centered = ellipse_iris - circle_center cc = self._squared_relative_radii(circle_iris_centered, circle_pupil_centered) ee = self._squared_relative_radii(ellipse_iris_centered, ellipse_pupil_centered) ec = self._squared_relative_radii(ec_iris_centered, circle_pupil_centered) cc_reg = self._relative_std(cc) ee_reg = self._relative_std(ee) ec_reg = self._relative_std(ec) radius_std_iris = self._relative_std(rhos_iris) radius_std_pupil = self._relative_std(rhos_pupil) min_reg = min(ee_reg, ec_reg) spread_ok = (radius_std_iris + radius_std_pupil) >= self.params.algorithm_switch_std_threshold reg_ok = min_reg <= (cc_reg + radius_std_iris * self.params.algorithm_switch_std_conditioned_multiplier) if spread_ok and reg_ok: if ee_reg <= ec_reg: return GeometryPolygons( pupil_array=ellipse_poly.pupil_array, iris_array=ellipse_poly.iris_array, eyeball_array=input_polygons.eyeball_array, ) else: return GeometryPolygons( pupil_array=circle_poly.pupil_array, iris_array=ellipse_poly.iris_array, eyeball_array=input_polygons.eyeball_array, ) return circle_poly