11import numpy as np
2+ import matplotlib .pyplot as plt
23
34
45class CalibrationEllipse :
@@ -10,14 +11,13 @@ def __init__(self, n_std_devs=2.5):
1011
1112 self .scale_factor = 0.80
1213
13- self .flip_y = False # Set to True if up/down are backwards
14- self .flip_x = False # Adjust if left/right are backwards
14+ self .flip_y = False
15+ self .flip_x = True
1516
16- # Ellipse parameters
17- self .center = None # Mean pupil position (ellipse center)
18- self .axes = None # Semi-axes (std_dev based)
19- self .rotation = None # Rotation angle
20- self .evecs = None # Eigenvectors
17+ # Parameters
18+ self .center = None
19+ self .axes = None
20+ self .evecs = None
2121
2222 def add_sample (self , x , y ):
2323 self .xs .append (float (x ))
@@ -29,224 +29,164 @@ def set_inset_percent(self, percent_smaller=0.0):
2929 self .scale_factor = 1.0 - (clamped_percent / 100.0 )
3030
3131 def init_from_save (self , evecs , axes ):
32- """Initialize calibration from saved data with validation"""
32+ """
33+ Initialize from save.
34+ NOTE: We ignore the saved 'evecs' rotation to ensure strict axis alignment.
35+ """
3336 try :
34- evecs_array = np .asarray (evecs , dtype = float )
3537 axes_array = np .asarray (axes , dtype = float )
3638
37- # Validate evecs shape
38- if evecs_array .shape != (2 , 2 ):
39- print (
40- f"\033 [91m[ERROR] Invalid evecs shape in saved data: { evecs_array .shape } . Expected (2, 2).\033 [0m"
41- )
42- self .fitted = False
43- return False
44-
45- # Validate axes shape
4639 if axes_array .shape != (2 ,):
47- print (f"\033 [91m[ERROR] Invalid axes shape in saved data: { axes_array .shape } . Expected (2,).\033 [0m" )
48- self .fitted = False
40+ print (f"[ERROR] Invalid axes shape: { axes_array .shape } ." )
4941 return False
5042
51- # Check for zero or invalid values
52- if np .all (axes_array == 0 ) or np .any (np .isnan (axes_array )) or np .any (np .isnan (evecs_array )):
53- print ("\033 [91m[ERROR] Saved calibration data contains zero or NaN values.\033 [0m" )
54- self .fitted = False
43+ if np .all (axes_array == 0 ) or np .any (np .isnan (axes_array )):
44+ print ("[ERROR] Saved data contains zero or NaN values." )
5545 return False
5646
57- self .evecs = evecs_array
47+ # Force Identity Matrix (No Rotation)
48+ self .evecs = np .eye (2 )
5849 self .axes = axes_array
50+
5951 self .fitted = True
6052 return True
6153
6254 except (ValueError , TypeError ) as e :
63- print (f"\033 [91m[ ERROR] Failed to load calibration data: { e } \033 [0m " )
55+ print (f"[ ERROR] Failed to load calibration data: { e } " )
6456 self .fitted = False
6557 return False
6658
6759 def fit_ellipse (self ):
60+ """
61+ Fits an axis-aligned ellipse (no rotation) using standard deviation.
62+ """
6863 N = len (self .xs )
6964 if N < 2 :
70- print ("Warning: Need >= 2 samples to fit PCA. Fit failed ." )
65+ print ("Warning: Need >= 2 samples to fit." )
7166 self .fitted = False
7267 return 0 , 0
7368
74- points = np .column_stack ([self .xs , self .ys ])
75- self .center = np .mean (points , axis = 0 )
76- centered_points = points - self .center
69+ # 1. Calculate Center (Mean)
70+ mean_x = np .mean (self .xs )
71+ mean_y = np .mean (self .ys )
72+ self .center = np .array ([mean_x , mean_y ])
7773
78- cov = np .cov (centered_points , rowvar = False )
74+ # 2. Calculate Axis Lengths (Standard Deviation)
75+ std_x = np .std (self .xs )
76+ std_y = np .std (self .ys )
7977
80- try :
81- evals_cov , evecs_cov = np .linalg .eigh (cov )
82- except np .linalg .LinAlgError :
83- self .fitted = False
84- return 0 , 0
85-
86- # Sort eigenvectors by alignment with screen axes (X, Y)
87- x_alignment = np .abs (evecs_cov [0 , :]) # How much each evec points in X direction
88-
89- if x_alignment [0 ] > x_alignment [1 ]:
90- # evec 0 is more X-aligned, evec 1 is more Y-aligned
91- self .evecs = evecs_cov
92- std_devs = np .sqrt (evals_cov )
93- else :
94- # evec 1 is more X-aligned, swap them
95- self .evecs = evecs_cov [:, [1 , 0 ]]
96- std_devs = np .sqrt (evals_cov [[1 , 0 ]])
78+ # Apply sigma multiplier
79+ radius_x = std_x * self .n_std_devs
80+ radius_y = std_y * self .n_std_devs
9781
98- # --- FIX STARTS HERE ---
99- # 1. Ensure the X-aligned eigenvector points Right (Positive X)
100- if self .evecs [0 , 0 ] < 0 :
101- self .evecs [:, 0 ] *= - 1
82+ # Safety clamp
83+ if radius_x < 1e-12 :
84+ radius_x = 1e-12
85+ if radius_y < 1e-12 :
86+ radius_y = 1e-12
10287
103- # 2. Ensure Y-aligned eigenvector maintains a Right-Handed Coordinate System.
104- # Instead of checking Y-sign independently, check the Determinant.
105- # In screen coords (Y down), X=(1,0) and Y=(0,1) gives det = 1.
106- # If det < 0, the axes are mirrored; we flip Y to fix it.
107- det = (self .evecs [0 , 0 ] * self .evecs [1 , 1 ]) - (self .evecs [0 , 1 ] * self .evecs [1 , 0 ])
88+ self .axes = np .array ([radius_x , radius_y ])
10889
109- if det < 0 :
110- self .evecs [:, 1 ] *= - 1
111- # --- FIX ENDS HERE ---
112-
113- self .axes = std_devs * self .n_std_devs
114-
115- if self .axes [0 ] < 1e-12 :
116- self .axes [0 ] = 1e-12
117- if self .axes [1 ] < 1e-12 :
118- self .axes [1 ] = 1e-12
119-
120- major_index = np .argmax (std_devs )
121- major_vec = self .evecs [:, major_index ]
122- self .rotation = np .arctan2 (major_vec [1 ], major_vec [0 ])
90+ # 3. Force Identity Matrix (Strict Horizontal/Vertical alignment)
91+ self .evecs = np .eye (2 )
12392
12493 self .fitted = True
12594 return self .evecs , self .axes
12695
127- def fit_and_visualize (self ):
128- try :
129- import matplotlib .pyplot as plt
130- except ImportError :
131- print ("\033 [91m[ERROR] matplotlib is required for visualization.\033 [0m" )
132- return
133-
134- plt .figure (figsize = (10 , 8 ))
135- plt .plot (self .xs , self .ys , "k." , label = "Calibration Samples" , alpha = 0.5 , markersize = 8 )
136- plt .axis ("equal" )
137- plt .grid (True , alpha = 0.3 )
138- plt .xlabel ("Pupil X (pixels)" )
139- plt .ylabel ("Pupil Y (pixels)" )
140-
96+ def normalize (self , pupil_pos , target_pos = None , clip = True ):
14197 if not self .fitted :
142- self . fit_ellipse ()
98+ return 0.0 , 0.0
14399
144- if self .fitted :
145- scaled_axes = self .axes * self .scale_factor
100+ x , y = float (pupil_pos [0 ]), float (pupil_pos [1 ])
146101
147- t = np .linspace (0 , 2 * np .pi , 200 )
148- local_coords = np .column_stack ([scaled_axes [0 ] * np .cos (t ), scaled_axes [1 ] * np .sin (t )])
149- world_coords = (self .evecs @ local_coords .T ).T + self .center
150-
151- plt .plot (
152- world_coords [:, 0 ],
153- world_coords [:, 1 ],
154- "b-" ,
155- linewidth = 2 ,
156- label = f"Calibration Ellipse ({ self .scale_factor * 100 :.0f} % scale)" ,
157- )
158- plt .plot (self .center [0 ], self .center [1 ], "r+" , markersize = 15 , markeredgewidth = 3 , label = "Ellipse Center (Mean)" )
159-
160- # Draw principal axes
161- for i , (axis_len , color , name ) in enumerate (
162- [(scaled_axes [0 ], "g" , "Major" ), (scaled_axes [1 ], "m" , "Minor" )]
163- ):
164- axis_vec = self .evecs [:, i ] * axis_len
165- plt .arrow (
166- self .center [0 ],
167- self .center [1 ],
168- axis_vec [0 ],
169- axis_vec [1 ],
170- head_width = 5 ,
171- head_length = 7 ,
172- fc = color ,
173- ec = color ,
174- alpha = 0.6 ,
175- label = f"{ name } Axis" ,
176- )
177-
178- plt .title (f"Eye Tracking Calibration Ellipse (PCA, { self .n_std_devs } σ)" )
102+ if target_pos is None :
103+ cx , cy = self .center
179104 else :
180- plt . title ( "Ellipse Fit FAILED (Not enough points)" )
105+ cx , cy = target_pos
181106
182- plt . legend ()
183- plt . tight_layout ()
184- plt . show ()
107+ # Calculate deltas
108+ dx = x - cx
109+ dy = y - cy
185110
186- def normalize (self , pupil_pos , target_pos = None , clip = True ):
187- if not self .fitted :
188- return 0.0 , 0.0
111+ # Get calibration radii
112+ rx , ry = self .axes * self .scale_factor
189113
190- if self . evecs is None or self . axes is None :
191- print ( " \033 [91m[ERROR] Calibration data (evecs/axes) is None. Please calibrate. \033 [0m" )
192- return 0.0 , 0.0
114+ # Normalize
115+ norm_x = dx / rx
116+ norm_y = dy / ry
193117
194- if not isinstance ( self . evecs , np . ndarray ) or self . evecs . shape != ( 2 , 2 ):
195- print ( f" \033 [91m[ERROR] Invalid evecs shape. Expected (2, 2). Please recalibrate. \033 [0m" )
196- return 0.0 , 0.0
118+ # --- APPLY FLIPS ---
119+ # If flip_x is True: Inverts the sign.
120+ final_x = - norm_x if self . flip_x else norm_x
197121
198- if not isinstance (self .axes , np .ndarray ) or self .axes .shape != (2 ,):
199- print (f"\033 [91m[ERROR] Invalid axes shape. Expected (2,). Please recalibrate.\033 [0m" )
200- return 0.0 , 0.0
122+ # If flip_y is False: Inverts Screen Y (so Up is Positive).
123+ final_y = norm_y if self .flip_y else - norm_y
124+
125+ if clip :
126+ final_x = np .clip (final_x , - 1.0 , 1.0 )
127+ final_y = np .clip (final_y , - 1.0 , 1.0 )
201128
202- if np .all (self .axes == 0 ) or np .any (np .isnan (self .axes )):
203- print ("\033 [91m[ERROR] Calibration axes are zero or invalid. Please recalibrate.\033 [0m" )
129+ return float (final_x ), float (final_y )
130+
131+ def denormalize (self , norm_x , norm_y , target_pos = None ):
132+ if not self .fitted :
204133 return 0.0 , 0.0
205134
206- x , y = float (pupil_pos [0 ]), float (pupil_pos [1 ])
207- p = np .array ([x , y ], dtype = float )
135+ # 1. Reverse the Output Mapping
136+ nx = - norm_x if self .flip_x else norm_x
137+ ny = norm_y if self .flip_y else - norm_y
208138
139+ # 2. Scale back up
140+ rx , ry = self .axes * self .scale_factor
141+ dx = nx * rx
142+ dy = ny * ry
143+
144+ # 3. Add Center
209145 if target_pos is None :
210- reference = self .center
146+ cx , cy = self .center
211147 else :
212- reference = np . asarray ( target_pos , dtype = float )
148+ cx , cy = target_pos
213149
214- p_centered = p - reference
150+ return float ( cx + dx ), float ( cy + dy )
215151
216- try :
217- p_rot = self .evecs .T @ p_centered
218- except (ValueError , TypeError ) as e :
219- print (f"\033 [91m[ERROR] Matrix multiplication failed in normalize: { e } . Please recalibrate.\033 [0m" )
220- return 0.0 , 0.0
152+ def fit_and_visualize (self ):
153+ plt .figure (figsize = (10 , 8 ))
221154
222- scaled_axes = self .axes * self .scale_factor
223- scaled_axes [scaled_axes < 1e-12 ] = 1e-12
155+ plt .plot (self .xs , self .ys , 'k.' , label = 'Samples' , alpha = 0.5 )
156+ plt .axis ('equal' )
157+ plt .grid (True , alpha = 0.3 )
224158
225- norm = p_rot / scaled_axes
159+ # Invert plot Y axis to match screen coordinates
160+ plt .gca ().invert_yaxis ()
226161
227- norm_x = - norm [ 0 ] if self .flip_x else norm [ 0 ]
228- norm_y = norm [ 1 ] if self .flip_y else - norm [ 1 ]
162+ if not self .fitted :
163+ self .fit_ellipse ()
229164
230- if clip :
231- norm_x = np . clip ( norm_x , - 1.0 , 1.0 )
232- norm_y = np .clip ( norm_y , - 1.0 , 1.0 )
165+ if self . fitted :
166+ scaled_axes = self . axes * self . scale_factor
167+ t = np .linspace ( 0 , 2 * np . pi , 200 )
233168
234- return float (norm_x ), float (norm_y )
169+ el_x = self .center [0 ] + scaled_axes [0 ] * np .cos (t )
170+ el_y = self .center [1 ] + scaled_axes [1 ] * np .sin (t )
235171
236- def denormalize (self , norm_x , norm_y , target_pos = None ):
237- if not self .fitted :
238- print ("ERROR: Ellipse not fitted yet." )
239- return 0.0 , 0.0
172+ plt .plot (el_x , el_y , 'b-' , linewidth = 2 , label = 'Axis-Aligned Fit' )
173+ plt .plot (self .center [0 ], self .center [1 ], 'r+' , markersize = 15 , label = 'Center' )
240174
241- nx = - norm_x if self .flip_x else norm_x
242- ny = norm_y if self .flip_y else - norm_y
175+ plt .hlines (self .center [1 ],
176+ self .center [0 ] - scaled_axes [0 ],
177+ self .center [0 ] + scaled_axes [0 ],
178+ colors = 'g' , linestyles = '-' , label = 'Width (X)' )
243179
244- scaled_axes = self .axes * self .scale_factor
245- p_rot = np .array ([nx , ny ]) * scaled_axes
180+ plt .vlines (self .center [0 ],
181+ self .center [1 ] - scaled_axes [1 ],
182+ self .center [1 ] + scaled_axes [1 ],
183+ colors = 'm' , linestyles = '-' , label = 'Height (Y)' )
246184
247- p_centered = self . evecs @ p_rot
248- reference = self . center if target_pos is None else np . asarray ( target_pos , dtype = float )
249- p = p_centered + reference
185+ plt . title ( f'Axis-Aligned Calibration (FlipX= { self . flip_x } )' )
186+ else :
187+ plt . title ( "Fit FAILED" )
250188
251- return float (p [0 ]), float (p [1 ])
189+ plt .legend ()
190+ plt .tight_layout ()
191+ plt .show ()
252192
0 commit comments