Skip to content

Commit 67d4a6c

Browse files
authored
Merge pull request #19 from RedHawk989/master
Port ellipse calibration method
2 parents 0330f46 + 4df7ac0 commit 67d4a6c

5 files changed

Lines changed: 361 additions & 3 deletions

File tree

eyetrackvr_backend/calibration.py

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import numpy as np
2+
import matplotlib.pyplot as plt
3+
4+
5+
class CalibrationEllipse:
6+
def __init__(self, n_std_devs=2.5):
7+
self.xs = []
8+
self.ys = []
9+
self.n_std_devs = float(n_std_devs)
10+
self.fitted = False
11+
12+
self.scale_factor = 0.80
13+
14+
self.flip_y = False
15+
self.flip_x = True
16+
17+
# Parameters
18+
self.center = None
19+
self.axes = None
20+
self.evecs = None
21+
22+
def add_sample(self, x, y):
23+
self.xs.append(float(x))
24+
self.ys.append(float(y))
25+
self.fitted = False
26+
27+
def set_inset_percent(self, percent_smaller=0.0):
28+
clamped_percent = np.clip(percent_smaller, 0.0, 100.0)
29+
self.scale_factor = 1.0 - (clamped_percent / 100.0)
30+
31+
def init_from_save(self, evecs, axes):
32+
"""
33+
Initialize from save.
34+
NOTE: We ignore the saved 'evecs' rotation to ensure strict axis alignment.
35+
"""
36+
try:
37+
axes_array = np.asarray(axes, dtype=float)
38+
39+
if axes_array.shape != (2,):
40+
print(f"[ERROR] Invalid axes shape: {axes_array.shape}.")
41+
return False
42+
43+
if np.all(axes_array == 0) or np.any(np.isnan(axes_array)):
44+
print("[ERROR] Saved data contains zero or NaN values.")
45+
return False
46+
47+
# Force Identity Matrix (No Rotation)
48+
self.evecs = np.eye(2)
49+
self.axes = axes_array
50+
51+
self.fitted = True
52+
return True
53+
54+
except (ValueError, TypeError) as e:
55+
print(f"[ERROR] Failed to load calibration data: {e}")
56+
self.fitted = False
57+
return False
58+
59+
def fit_ellipse(self):
60+
"""
61+
Fits an axis-aligned ellipse (no rotation) using standard deviation.
62+
"""
63+
N = len(self.xs)
64+
if N < 2:
65+
print("Warning: Need >= 2 samples to fit.")
66+
self.fitted = False
67+
return 0, 0
68+
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])
73+
74+
# 2. Calculate Axis Lengths (Standard Deviation)
75+
std_x = np.std(self.xs)
76+
std_y = np.std(self.ys)
77+
78+
# Apply sigma multiplier
79+
radius_x = std_x * self.n_std_devs
80+
radius_y = std_y * self.n_std_devs
81+
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
87+
88+
self.axes = np.array([radius_x, radius_y])
89+
90+
# 3. Force Identity Matrix (Strict Horizontal/Vertical alignment)
91+
self.evecs = np.eye(2)
92+
93+
self.fitted = True
94+
return self.evecs, self.axes
95+
96+
def normalize(self, pupil_pos, target_pos=None, clip=True):
97+
if not self.fitted:
98+
return 0.0, 0.0
99+
100+
x, y = float(pupil_pos[0]), float(pupil_pos[1])
101+
102+
if target_pos is None:
103+
cx, cy = self.center
104+
else:
105+
cx, cy = target_pos
106+
107+
# Calculate deltas
108+
dx = x - cx
109+
dy = y - cy
110+
111+
# Get calibration radii
112+
rx, ry = self.axes * self.scale_factor
113+
114+
# Normalize
115+
norm_x = dx / rx
116+
norm_y = dy / ry
117+
118+
# --- APPLY FLIPS ---
119+
# If flip_x is True: Inverts the sign.
120+
final_x = -norm_x if self.flip_x else norm_x
121+
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)
128+
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:
133+
return 0.0, 0.0
134+
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
138+
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
145+
if target_pos is None:
146+
cx, cy = self.center
147+
else:
148+
cx, cy = target_pos
149+
150+
return float(cx + dx), float(cy + dy)
151+
152+
def fit_and_visualize(self):
153+
plt.figure(figsize=(10, 8))
154+
155+
plt.plot(self.xs, self.ys, "k.", label="Samples", alpha=0.5)
156+
plt.axis("equal")
157+
plt.grid(True, alpha=0.3)
158+
159+
# Invert plot Y axis to match screen coordinates
160+
plt.gca().invert_yaxis()
161+
162+
if not self.fitted:
163+
self.fit_ellipse()
164+
165+
if self.fitted:
166+
scaled_axes = self.axes * self.scale_factor
167+
t = np.linspace(0, 2 * np.pi, 200)
168+
169+
el_x = self.center[0] + scaled_axes[0] * np.cos(t)
170+
el_y = self.center[1] + scaled_axes[1] * np.sin(t)
171+
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")
174+
175+
plt.hlines(
176+
self.center[1],
177+
self.center[0] - scaled_axes[0],
178+
self.center[0] + scaled_axes[0],
179+
colors="g",
180+
linestyles="-",
181+
label="Width (X)",
182+
)
183+
184+
plt.vlines(
185+
self.center[0],
186+
self.center[1] - scaled_axes[1],
187+
self.center[1] + scaled_axes[1],
188+
colors="m",
189+
linestyles="-",
190+
label="Height (Y)",
191+
)
192+
193+
plt.title(f"Axis-Aligned Calibration (FlipX={self.flip_x})")
194+
else:
195+
plt.title("Fit FAILED")
196+
197+
plt.legend()
198+
plt.tight_layout()
199+
plt.show()

eyetrackvr_backend/etvr.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@
55
from .config import ConfigManager
66
from multiprocessing import Manager
77
from .logger import get_logger
8-
from fastapi import APIRouter
8+
from fastapi import APIRouter, HTTPException
99
from .tracker import Tracker
1010

1111
logger = get_logger()
1212

13+
1314
class ETVR:
1415
def __init__(self):
1516
self.running: bool = False
@@ -89,6 +90,21 @@ def shutdown(self) -> None:
8990
self.config.stop()
9091
os.kill(os.getpid(), signal.SIGTERM)
9192

93+
def _get_tracker(self, uuid: str) -> Tracker:
94+
for tracker in self.trackers:
95+
if tracker.uuid == uuid:
96+
return tracker
97+
raise HTTPException(status_code=404, detail=f"No tracker found with UUID `{uuid}`")
98+
99+
def tracker_recenter(self, uuid: str) -> dict:
100+
return self._get_tracker(uuid).recenter()
101+
102+
def tracker_calibrate(self, uuid: str) -> dict:
103+
return self._get_tracker(uuid).calibrate()
104+
105+
def tracker_calibration_state(self, uuid: str) -> dict:
106+
return self._get_tracker(uuid).check_state()
107+
92108
def add_routes(self) -> None:
93109
logger.debug("Adding routes to ETVR")
94110
# region: Image streaming endpoints
@@ -159,6 +175,38 @@ def add_routes(self) -> None:
159175
""",
160176
)
161177
# endregion
178+
# region: Tracker Calibration Endpoints
179+
self.router.add_api_route(
180+
name="Recenter tracker calibration",
181+
path="/etvr/tracker/{uuid}/recenter",
182+
endpoint=self.tracker_recenter,
183+
methods=["GET"],
184+
tags=["Tracker Calibration"],
185+
description="""
186+
Recenter the calibration for a tracker.
187+
""",
188+
)
189+
self.router.add_api_route(
190+
name="Toggle tracker calibration",
191+
path="/etvr/tracker/{uuid}/calibrate",
192+
endpoint=self.tracker_calibrate,
193+
methods=["GET"],
194+
tags=["Tracker Calibration"],
195+
description="""
196+
Toggle calibration mode for a tracker. Calling again stops calibration and fits the ellipse.
197+
""",
198+
)
199+
self.router.add_api_route(
200+
name="Get tracker calibration state",
201+
path="/etvr/tracker/{uuid}/calibration/state",
202+
endpoint=self.tracker_calibration_state,
203+
methods=["GET"],
204+
tags=["Tracker Calibration"],
205+
description="""
206+
Return the current calibration state for a tracker.
207+
""",
208+
)
209+
# endregion
162210
# region: Config Endpoints
163211
self.router.add_api_route(
164212
name="Update Config",
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
from .camera import Camera
22
from .eye_processor import EyeProcessor
33
from .osc import VRChatOSC, VRChatOSCReceiver
4+
from .calibration import CalibrationProcessor
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from queue import Queue, Empty, Full
2+
import numpy as np
3+
4+
from ..types import EyeData, TRACKING_FAILED
5+
from ..utils import WorkerProcess
6+
from ..calibration import CalibrationEllipse
7+
8+
9+
class CalibrationProcessor(WorkerProcess):
10+
def __init__(
11+
self,
12+
input_queue: Queue[EyeData],
13+
output_queue: Queue[EyeData],
14+
state,
15+
name: str,
16+
uuid: str,
17+
):
18+
super().__init__(name=f"Calibration {name}", uuid=uuid)
19+
# Synced variables
20+
self.input_queue = input_queue
21+
self.output_queue = output_queue
22+
self.state = state
23+
# Unsynced variables
24+
self.calibration = CalibrationEllipse()
25+
self.recenter_reference: np.ndarray | None = None
26+
self._prev_calibrating = False
27+
28+
def startup(self) -> None:
29+
pass
30+
31+
def run(self) -> None:
32+
try:
33+
eye_data: EyeData = self.input_queue.get(block=True, timeout=0.5)
34+
except Empty:
35+
return
36+
except Exception:
37+
self.logger.exception("Failed to get eye data from queue")
38+
return
39+
40+
calibrating = bool(self.state.get("calibrating", False))
41+
if calibrating and not self._prev_calibrating:
42+
self.calibration = CalibrationEllipse()
43+
self.state["calibrated"] = False
44+
self.state["samples"] = 0
45+
46+
if not calibrating and self._prev_calibrating:
47+
self.calibration.fit_ellipse()
48+
self.state["calibrated"] = bool(self.calibration.fitted)
49+
self.state["samples"] = len(self.calibration.xs)
50+
51+
self._prev_calibrating = calibrating
52+
53+
if self.state.get("recenter_requested", False):
54+
self.recenter_reference = np.array([eye_data.x, eye_data.y], dtype=float)
55+
self.state["recenter_requested"] = False
56+
self.state["recentered"] = True
57+
58+
if calibrating and eye_data != TRACKING_FAILED:
59+
self.calibration.add_sample(eye_data.x, eye_data.y)
60+
self.state["samples"] = len(self.calibration.xs)
61+
62+
if self.calibration.fitted:
63+
norm_x, norm_y = self.calibration.normalize((eye_data.x, eye_data.y), target_pos=self.recenter_reference)
64+
eye_data.x = (norm_x + 1.0) / 2.0
65+
eye_data.y = (norm_y + 1.0) / 2.0
66+
67+
try:
68+
self.output_queue.put(eye_data, block=False)
69+
except Full:
70+
pass
71+
72+
def shutdown(self) -> None:
73+
pass

0 commit comments

Comments
 (0)