Skip to content

Commit b35fc79

Browse files
authored
Merge pull request #221 from worldcoin/yichen1/aib-1661-rewrite-eye-center-calculation
Yichen1/aib 1661 rewrite eye center calculation
2 parents 275287e + 02697a2 commit b35fc79

File tree

9 files changed

+239
-4
lines changed

9 files changed

+239
-4
lines changed
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
from typing import Tuple
2+
3+
import cv2
4+
import numpy as np
5+
from pydantic import Field
6+
7+
from iris.io.class_configs import Algorithm
8+
from iris.io.dataclasses import EyeCenters, GeometryPolygons
9+
from iris.io.errors import EyeCentersEstimationError
10+
11+
12+
class CircleFitEyeCenterMethod(Algorithm):
13+
"""Estimate pupil and iris centers using a robust circle-fitting approach.
14+
15+
This algorithm estimates the center of a pupil or iris polygon using:
16+
17+
1. an initial least-squares circle fit to all polygon points,
18+
2. trimming of outlier points based on radial residuals using a dynamic threshold,
19+
3. a second circle fit on the retained inliers.
20+
21+
LIMITATIONS:
22+
This method assumes that the pupil and iris contours are reasonably well
23+
approximated by circles in image space. It is therefore most appropriate
24+
when off-gaze and strong perspective distortion have already been filtered out.
25+
"""
26+
27+
class Parameters(Algorithm.Parameters):
28+
"""Default parameters for circle-fit eye center algorithm."""
29+
30+
mad_scale: float = Field(..., gt=0.0)
31+
32+
__parameters_type__ = Parameters
33+
34+
def __init__(
35+
self,
36+
mad_scale: float = 3.0,
37+
) -> None:
38+
"""Assign parameters.
39+
40+
Args:
41+
mad_scale (float, optional): Scale factor used in the dynamic inlier
42+
threshold: median(residuals) + mad_scale * MAD(residuals).
43+
Defaults to 3.0.
44+
"""
45+
super().__init__(mad_scale=mad_scale)
46+
47+
def run(self, geometries: GeometryPolygons) -> EyeCenters:
48+
"""Estimate pupil and iris centers.
49+
50+
Args:
51+
geometries (GeometryPolygons): Pupil and iris geometry polygons.
52+
53+
Returns:
54+
EyeCenters: Estimated pupil and iris center coordinates.
55+
"""
56+
pupil_center_x, pupil_center_y = self._calculate_circle_fit_center(geometries.pupil_array.astype(np.float64))
57+
iris_center_x, iris_center_y = self._calculate_circle_fit_center(geometries.iris_array.astype(np.float64))
58+
59+
return EyeCenters(
60+
pupil_x=pupil_center_x,
61+
pupil_y=pupil_center_y,
62+
iris_x=iris_center_x,
63+
iris_y=iris_center_y,
64+
)
65+
66+
def _calculate_circle_fit_center(self, polygon: np.ndarray) -> Tuple[float, float]:
67+
"""Estimate the center of a polygon using fit -> trim outliers -> refit.
68+
69+
Args:
70+
polygon (np.ndarray): Polygon points of shape (N, 2) representing a
71+
contour that is approximately circular.
72+
73+
Raises:
74+
EyeCentersEstimationError: Raised if the polygon is invalid or if a
75+
valid circle cannot be fit.
76+
77+
Returns:
78+
Tuple[float, float]: Estimated center coordinates (x, y).
79+
"""
80+
if polygon.ndim != 2 or polygon.shape[1] != 2 or polygon.shape[0] < 3:
81+
raise EyeCentersEstimationError("Polygon must have shape (N, 2) with at least 3 points")
82+
83+
pts = polygon.astype(np.float64, copy=False)
84+
85+
# Initial least-squares circle fit
86+
x = pts[:, 0]
87+
y = pts[:, 1]
88+
89+
A = np.column_stack([x, y, np.ones_like(x)]).astype(np.float64)
90+
b = (-(x * x + y * y)).reshape(-1, 1).astype(np.float64)
91+
92+
ok, coeffs = cv2.solve(A, b, flags=cv2.DECOMP_SVD)
93+
if not ok:
94+
raise EyeCentersEstimationError("Circle fit failed")
95+
96+
a, b_, c = coeffs.ravel()
97+
98+
cx = -a / 2.0
99+
cy = -b_ / 2.0
100+
101+
r_sq = cx * cx + cy * cy - c
102+
if r_sq <= 0:
103+
raise EyeCentersEstimationError("Failed to fit a valid circle to the polygon")
104+
105+
r = np.sqrt(r_sq)
106+
center = np.array([cx, cy], dtype=np.float64)
107+
108+
# Trim outliers using dynamic radial residual threshold
109+
d = np.linalg.norm(pts - center, axis=1)
110+
residuals = np.abs(d - r)
111+
112+
median_residual = np.median(residuals)
113+
mad = np.median(np.abs(residuals - median_residual))
114+
115+
if mad == 0:
116+
keep_mask = residuals <= median_residual
117+
else:
118+
threshold = median_residual + self.params.mad_scale * mad
119+
keep_mask = residuals <= threshold
120+
121+
inliers = pts[keep_mask]
122+
123+
# Ensure enough points remain to refit
124+
if len(inliers) < 3:
125+
keep_idx = np.argsort(residuals)[:3]
126+
inliers = pts[keep_idx]
127+
128+
# Refit circle on inliers
129+
x = inliers[:, 0]
130+
y = inliers[:, 1]
131+
132+
A = np.column_stack([x, y, np.ones_like(x)])
133+
b = -(x * x + y * y)
134+
135+
coeffs, _, _, _ = np.linalg.lstsq(A, b, rcond=None)
136+
137+
a, b_, c = coeffs
138+
cx = -a / 2.0
139+
cy = -b_ / 2.0
140+
141+
r_sq = cx * cx + cy * cy - c
142+
if r_sq <= 0:
143+
raise EyeCentersEstimationError("Failed to refit a valid circle to the inlier polygon points")
144+
145+
return float(cx), float(cy)

tests/e2e_tests/nodes/eye_properties_estimation/mocks/bisectors_method/e2e_expected_result.pickle renamed to tests/e2e_tests/nodes/eye_properties_estimation/mocks/eye_center_method/bisector_method_e2e_expected_result.pickle

File renamed without changes.

tests/e2e_tests/nodes/eye_properties_estimation/mocks/bisectors_method/geometry_polygons.pickle renamed to tests/e2e_tests/nodes/eye_properties_estimation/mocks/eye_center_method/geometry_polygons.pickle

File renamed without changes.

tests/e2e_tests/nodes/eye_properties_estimation/test_e2e_bisectors_method.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010

1111
def load_mock_pickle(name: str) -> Any:
12-
testdir = os.path.join(os.path.dirname(__file__), "mocks", "bisectors_method")
12+
testdir = os.path.join(os.path.dirname(__file__), "mocks", "eye_center_method")
1313

1414
mock_path = os.path.join(testdir, f"{name}.pickle")
1515

@@ -23,7 +23,7 @@ def algorithm() -> BisectorsMethod:
2323

2424
def test_e2e_bisectors_method_algorithm(algorithm: BisectorsMethod) -> None:
2525
mock_polygons = load_mock_pickle(name="geometry_polygons")
26-
expected_result = load_mock_pickle(name="e2e_expected_result")
26+
expected_result = load_mock_pickle(name="bisector_method_e2e_expected_result")
2727

2828
result = algorithm(geometries=mock_polygons)
2929

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import math
2+
import os
3+
import pickle
4+
from typing import Any
5+
6+
import pytest
7+
8+
from iris.nodes.eye_properties_estimation.circle_fit_for_eye_center_method import CircleFitEyeCenterMethod
9+
10+
11+
def load_mock_pickle(name: str) -> Any:
12+
testdir = os.path.join(os.path.dirname(__file__), "mocks", "eye_center_method")
13+
14+
mock_path = os.path.join(testdir, f"{name}.pickle")
15+
16+
return pickle.load(open(mock_path, "rb"))
17+
18+
19+
@pytest.fixture
20+
def algorithm() -> CircleFitEyeCenterMethod:
21+
return CircleFitEyeCenterMethod(mad_scale=3.0)
22+
23+
24+
def test_e2e_bisectors_method_algorithm(algorithm: CircleFitEyeCenterMethod) -> None:
25+
mock_polygons = load_mock_pickle(name="geometry_polygons")
26+
expected_result = load_mock_pickle(name="circle_fit_method_e2e_expected_result")
27+
28+
result = algorithm(geometries=mock_polygons)
29+
30+
assert math.isclose(result.pupil_x, expected_result.pupil_x)
31+
assert math.isclose(result.pupil_y, expected_result.pupil_y)
32+
assert math.isclose(result.iris_x, expected_result.iris_x)
33+
assert math.isclose(result.iris_y, expected_result.iris_y)

tests/e2e_tests/nodes/eye_properties_estimation/test_e2e_pupil_iris_property_calculator.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,17 @@
44
from typing import Any
55

66
from iris.nodes.eye_properties_estimation.bisectors_method import BisectorsMethod
7+
from iris.nodes.eye_properties_estimation.circle_fit_for_eye_center_method import CircleFitEyeCenterMethod
78
from iris.nodes.eye_properties_estimation.pupil_iris_property_calculator import PupilIrisPropertyCalculator
89

910

1011
def load_mock_pickle(name: str) -> Any:
11-
testdir = os.path.join(os.path.dirname(__file__), "mocks", "bisectors_method")
12+
testdir = os.path.join(os.path.dirname(__file__), "mocks", "eye_center_method")
1213
mock_path = os.path.join(testdir, f"{name}.pickle")
1314
return pickle.load(open(mock_path, "rb"))
1415

1516

16-
def test_precomputed_pupil_iris_property() -> None:
17+
def test_precomputed_pupil_iris_property_bisector_method() -> None:
1718
mock_polygons = load_mock_pickle(name="geometry_polygons")
1819

1920
eye_center_obj = BisectorsMethod()
@@ -24,3 +25,15 @@ def test_precomputed_pupil_iris_property() -> None:
2425

2526
assert math.isclose(p2i_property.pupil_to_iris_diameter_ratio, 0.543019583685283)
2627
assert math.isclose(p2i_property.pupil_to_iris_center_dist_ratio, 0.032786957796171405)
28+
29+
def test_precomputed_pupil_iris_property_circle_fit_method() -> None:
30+
mock_polygons = load_mock_pickle(name="geometry_polygons")
31+
32+
eye_center_obj = CircleFitEyeCenterMethod()
33+
eye_center = eye_center_obj(mock_polygons)
34+
35+
pupil_iris_property_obj = PupilIrisPropertyCalculator()
36+
p2i_property = pupil_iris_property_obj(mock_polygons, eye_center)
37+
38+
assert math.isclose(p2i_property.pupil_to_iris_diameter_ratio, 0.543019583685283)
39+
assert math.isclose(p2i_property.pupil_to_iris_center_dist_ratio, 0.03771351406478552)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import math
2+
3+
import numpy as np
4+
import pytest
5+
6+
from iris.io.dataclasses import GeometryPolygons
7+
from iris.nodes.eye_properties_estimation.circle_fit_for_eye_center_method import CircleFitEyeCenterMethod
8+
from tests.unit_tests.utils import generate_arc
9+
10+
11+
@pytest.fixture
12+
def algorithm() -> CircleFitEyeCenterMethod:
13+
return CircleFitEyeCenterMethod(mad_scale=3.0)
14+
15+
16+
def test_estimation_on_mock_example(algorithm: CircleFitEyeCenterMethod) -> None:
17+
pupil_radius = 25.0
18+
iris_radius = 100.0
19+
eyeball_radius = 400.0
20+
pupil_center_x, pupil_center_y = 95.0, 145.0
21+
iris_center_x, iris_center_y = 100.0, 155.0
22+
23+
mock_polygons = GeometryPolygons(
24+
pupil_array=generate_arc(pupil_radius, pupil_center_x, pupil_center_y, 0.0, 2 * np.pi),
25+
iris_array=generate_arc(iris_radius, iris_center_x, iris_center_y, 0.0, 2 * np.pi),
26+
eyeball_array=generate_arc(eyeball_radius, iris_center_x, iris_center_y, 0.0, 2 * np.pi),
27+
)
28+
29+
result = algorithm(mock_polygons)
30+
31+
assert math.isclose(result.pupil_x, pupil_center_x, rel_tol=0.1)
32+
assert math.isclose(result.pupil_y, pupil_center_y, rel_tol=0.1)
33+
assert math.isclose(result.iris_x, iris_center_x, rel_tol=0.1)
34+
assert math.isclose(result.iris_y, iris_center_y, rel_tol=0.1)

tests/unit_tests/nodes/eye_properties_estimation/test_pupil_iris_property_calculator.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from iris.io.dataclasses import GeometryPolygons
88
from iris.nodes.eye_properties_estimation.bisectors_method import BisectorsMethod
9+
from iris.nodes.eye_properties_estimation.circle_fit_for_eye_center_method import CircleFitEyeCenterMethod
910
from iris.nodes.eye_properties_estimation.pupil_iris_property_calculator import (
1011
PupilIrisPropertyCalculator,
1112
PupilIrisPropertyEstimationError,
@@ -133,3 +134,12 @@ def test_pupil_iris_property(
133134
p2i_property = pupil_iris_property_obj(mock_polygons, eye_center)
134135
assert math.isclose(p2i_property.pupil_to_iris_diameter_ratio, expected_diameter_ratio, rel_tol=1e-03)
135136
assert math.isclose(p2i_property.pupil_to_iris_center_dist_ratio, expected_center_dist_ratio, rel_tol=1e-03)
137+
138+
eye_center_obj = CircleFitEyeCenterMethod()
139+
eye_center = eye_center_obj(mock_polygons)
140+
pupil_iris_property_obj = PupilIrisPropertyCalculator(
141+
min_pupil_diameter=min_pupil_diameter, min_iris_diameter=min_iris_diameter
142+
)
143+
p2i_property = pupil_iris_property_obj(mock_polygons, eye_center)
144+
assert math.isclose(p2i_property.pupil_to_iris_diameter_ratio, expected_diameter_ratio, rel_tol=1e-03)
145+
assert math.isclose(p2i_property.pupil_to_iris_center_dist_ratio, expected_center_dist_ratio, rel_tol=1e-03)

0 commit comments

Comments
 (0)