Skip to content

Commit 0e0d101

Browse files
committed
port fixed calibration from main app
1 parent 52b7b98 commit 0e0d101

1 file changed

Lines changed: 109 additions & 169 deletions

File tree

eyetrackvr_backend/calibration.py

Lines changed: 109 additions & 169 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import numpy as np
2+
import matplotlib.pyplot as plt
23

34

45
class 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

Comments
 (0)