Compare commits

...

8 Commits

Author SHA1 Message Date
Prohurtz
60fef008c8 bump version
Some checks failed
App Builder / build (ubuntu-latest) (push) Has been cancelled
App Builder / build (windows-latest) (push) Has been cancelled
App Builder / Deploy (push) Has been cancelled
App Builder / Cleanup actions (push) Has been cancelled
2025-10-30 17:22:02 -05:00
Prohurtz
2ddae43e95 fix config saving of new calibration 2025-10-30 17:21:09 -05:00
Prohurtz
ccb8596849 remove bsb2e work 2025-10-30 16:48:41 -05:00
Prohurtz
ad8c86b54c new ellipse calibration 2025-10-30 16:46:28 -05:00
Prohurtz
a0e74e6822 fix: BSSB2e widget failing to load 2025-10-30 15:48:47 -05:00
Prohurtz
6c8383ffe1 fix: partial BSSB2e imp 2025-10-30 15:48:42 -05:00
Prohurtz
b54686923c Beta new calibration 2025-10-30 15:48:26 -05:00
Prohurtz
f0a655b03b Beta new calibration 2025-10-30 15:38:30 -05:00
10 changed files with 1441 additions and 780 deletions

View File

@ -158,6 +158,68 @@ class PupilDetectorHaar:
self._img_boundary = (0, 0, 0, 0)
self._init_rect_down = (0, 0, 0, 0)
def detect_etvr(self, img_gray) -> Tuple[np.ndarray, np.ndarray, float, float, float]:
"""
Runs the full detection and returns a visualized image and ETVR-specific data.
Args:
img_gray: The input grayscale image (uint8).
Returns:
A tuple containing:
- vis_img (np.ndarray): The original image with visualizations drawn on it (BGR).
- resize_img (np.ndarray): The downscaled image used for processing.
- rawx (float): The final X coordinate of the pupil center.
- rawy (float): The final Y coordinate of the pupil center.
- radius (float): The calculated average radius of the final pupil rectangle.
"""
# 1. Run the main detection.
# This populates all internal class attributes:
# self.pupil_rect_fine, self.center_fine,
# self.pupil_rect_coarse, self.outer_rect_coarse,
# and self._ratio_down. It also increments self.frame_num.
self.detect(img_gray)
# 2. Get the downscaled image.
# We call _preprocess again. This is slightly inefficient but
# avoids refactoring detect(). It will correctly use the
# self.frame_num that detect() just set.
resize_img = img_gray
# 3. Get the final data from class attributes
rawx, rawy = self.center_fine
px, py, pw, ph = self.pupil_rect_fine
# Calculate an average radius from the fine rect's width and height
radius = (pw + ph) / 4.0
# 4. Create the visualization image
# Convert the original grayscale image to BGR for color drawing
vis_img = cv2.cvtColor(img_gray, cv2.COLOR_GRAY2BGR)
# Draw coarse pupil rect (Green)
x, y, w, h = self.pupil_rect_coarse
if w > 0 and h > 0:
cv2.rectangle(vis_img, (x, y), (x + w, y + h), (0, 255, 0), 1)
# Draw coarse outer rect (Yellow)
x, y, w, h = self.outer_rect_coarse
if w > 0 and h > 0:
cv2.rectangle(vis_img, (x, y), (x + w, y + h), (0, 255, 255), 1)
# Draw fine pupil rect (Red)
x, y, w, h = self.pupil_rect_fine
if w > 0 and h > 0:
cv2.rectangle(vis_img, (x, y), (x + w, y + h), (0, 0, 255), 1)
# Draw fine center (Red)
cv2.circle(vis_img, (int(round(rawx)), int(round(rawy))), 3, (0, 0, 255), -1)
vis_img = cv2.cvtColor(vis_img, cv2.COLOR_BGR2GRAY)
# 5. Return the requested 5-tuple
return vis_img, resize_img, rawx, rawy, radius
def detect(self, img_gray: np.ndarray) -> Tuple[Tuple[int, int, int, int], Tuple[float, float]]:
if img_gray.dtype != np.uint8:
raise TypeError("img_gray must be uint8 [0,255]")

View File

@ -2,7 +2,7 @@
; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
#define MyAppName "EyeTrackVR"
#define MyAppVersion "0.2.2"
#define MyAppVersion "0.2.4"
#define MyAppPublisher "EyeTrackVR"
#define MyAppURL "https://redhawk989.github.io/EyeTrackVR/"
#define MyAppExeName "eyetrackapp.exe"

View File

@ -27,18 +27,21 @@ LICENSE: Babble Software Distribution License 1.0
import json
import os.path
import shutil
import numpy as np
from colorama import Fore
from pydantic import BaseModel
from pydantic import BaseModel, field_validator
from typing import Any, Union, List
import os
from eye import EyeId
CONFIG_FILE_NAME: str = "eyetrack_settings.json"
BACKUP_CONFIG_FILE_NAME: str = "eyetrack_settings.backup"
from pydantic import BaseModel, field_validator, field_serializer
from typing import Any, Union, List
import numpy as np
class EyeTrackCameraConfig(BaseModel):
gui_rotation_ui_padding: bool = True
rotation_angle: int = 0
@ -48,10 +51,9 @@ class EyeTrackCameraConfig(BaseModel):
roi_window_h: int = 240
focal_length: int = 30
capture_source: Union[int, str, None] = None
calib_XMAX: Union[float, None] = None
calib_XMIN: Union[float, None] = None
calib_YMAX: Union[float, None] = None
calib_YMIN: Union[float, None] = None
calib_axes: Union[List[float], None] = None
calib_evecs: Union[List[List[float]], None] = None
calib_center: Union[List[float], None] = None
calib_XOFF: Union[float, None] = None
calib_YOFF: Union[float, None] = None
calibration_points: List[List[Union[float, None]]] = []
@ -60,6 +62,58 @@ class EyeTrackCameraConfig(BaseModel):
leap_calibration_percentile_2: float = 0
leap_calibrated: bool = False
@field_validator('calib_axes', 'calib_evecs', 'calib_center', mode='before')
@classmethod
def convert_numpy_to_list(cls, v):
"""Convert NumPy arrays to lists for JSON serialization"""
if v is None:
return None
if isinstance(v, np.ndarray):
return v.tolist()
if hasattr(v, 'tolist') and callable(v.tolist):
return v.tolist()
return v
@field_serializer('calib_axes', 'calib_evecs', 'calib_center')
def serialize_arrays(self, value):
"""Serialize arrays to lists when saving"""
if value is None:
return None
if isinstance(value, np.ndarray):
return value.tolist()
if hasattr(value, 'tolist') and callable(value.tolist):
return value.tolist()
return value
def get_calib_axes_array(self) -> Union[np.ndarray, None]:
"""Get calib_axes as a NumPy array"""
if self.calib_axes is None:
return None
return np.array(self.calib_axes, dtype=float)
def get_calib_evecs_array(self) -> Union[np.ndarray, None]:
"""Get calib_evecs as a NumPy array"""
if self.calib_evecs is None:
return None
return np.array(self.calib_evecs, dtype=float)
def get_calib_center_array(self) -> Union[np.ndarray, None]:
"""Get calib_center as a NumPy array"""
if self.calib_center is None:
return None
return np.array(self.calib_center, dtype=float)
def set_calibration_data(self, axes: np.ndarray, evecs: np.ndarray, center: np.ndarray):
"""Set all calibration data from NumPy arrays (auto-converts to lists)"""
self.calib_axes = axes.tolist()
self.calib_evecs = evecs.tolist()
self.calib_center = center.tolist()
def has_calibration_data(self) -> bool:
"""Check if calibration data is present"""
return (self.calib_axes is not None and
self.calib_evecs is not None and
self.calib_center is not None)
def update_capture_source(self, new_camera_address: str):
if not new_camera_address:
@ -82,29 +136,6 @@ class EyeTrackCameraConfig(BaseModel):
def update(self, data: dict[str, Any]) -> bool:
"""
Updates the model one field at a time based on the provided data dict.
The dict has to be defined like
```
data = {
"model_field": value
}
```
If stale data is provided,
ex. User clicked on save and restart but didn't provide a new field
we skip it, assuming that it was just a call to restart the tracking, or a miss-click.
Some fields may require more validation, we take care of that with special methods.
defining a method like
```
def update_custom_field(value: type):
pass
```
will cause it to be picked up by this method and called with the current value.
Return values are ignored.
"""
for key, value in data.items():
old_value = getattr(self, key, None)
@ -117,13 +148,12 @@ class EyeTrackCameraConfig(BaseModel):
if callable(update_attr):
update_attr(value)
else:
setattr(self, "key", value)
setattr(self, key, value)
return True
else:
print(f"\033[93m[WARN] Field {key} does not exist on {self}.\033[0m")
return False
class EyeTrackSettingsConfig(BaseModel):
gui_flip_x_axis_left: bool = False
gui_flip_x_axis_right: bool = False
@ -222,6 +252,7 @@ class EyeTrackConfig(BaseModel):
version: int = 1
right_eye: EyeTrackCameraConfig = EyeTrackCameraConfig()
left_eye: EyeTrackCameraConfig = EyeTrackCameraConfig()
bsb2e: EyeTrackCameraConfig = EyeTrackCameraConfig() # should we do independent per bsb eye?
settings: EyeTrackSettingsConfig = EyeTrackSettingsConfig()
eye_display_id: EyeId = EyeId.RIGHT
__listeners = []

View File

@ -36,6 +36,7 @@ class EyeId(IntEnum):
ALGOSETTINGS = 4
VRCFTMODULESETTINGS = 5
GUIOFF = 6
BSB2E = 7
class EyeInfoOrigin(Enum):

View File

@ -49,6 +49,9 @@ from intensity_based_openness import *
from ellipse_based_pupil_dilation import *
from AHSF import *
from osc.OSCMessage import OSCMessageType, OSCMessage
from utils.calibration_elipse import *
os.environ["OMP_NUM_THREADS"] = "1"
sys.path.append(".")
@ -165,7 +168,9 @@ class EyeProcessor:
self.pupil_height = 0.0
self.avg_velocity = 0.0
self.angle = 621
self.det = PupilDetectorHaar(ratio_outer=1.4, kf=1.4)
self.er_ahsf = None
self.cal = CalibrationEllipse()
self.AHSF = PupilDetectorHaar()
try:
@ -238,7 +243,7 @@ class EyeProcessor:
borderValue=(255, 255, 255),
)
inv_matrix = cv2.invertAffineTransform(matrix)
inv_matrix = np.linalg.inv(np.vstack((matrix, [0, 0, 1])))[:-1]
# calculate crop corner locations in original image space
corners = np.matmul([[0, 0, 1], [roi_w, 0, 1], [0, roi_h, 1], [roi_w, roi_h, 1]], np.transpose(inv_matrix))
fits_in_bounds = all(0 <= x <= img_w and 0 <= y <= img_h for (x, y) in corners)
@ -406,14 +411,16 @@ class EyeProcessor:
pass
self.hasrac_en = True
(
self.current_image_gray,
resize_img,
self.rawx,
self.rawy,
self.radius,
) = self.er_ahsf.detect_etvr(self.current_image_gray)
self.current_image_gray_clean = resize_img.copy()
self.current_image_gray_clean = self.current_image_gray.copy()
self.det.detect(self.current_image_gray)
cx, cy = map(int, self.det.center_fine)
cv2.circle(self.current_image_gray, (cx, cy), 3, (0, 0, 255), -1)
cv2.rectangle(self.current_image_gray, self.det.pupil_rect_fine, (0, 255, 0), 1)
self.thresh = self.current_image_gray_clean
self.thresh = resize_img
(
self.rawx,
self.rawy,
@ -520,11 +527,13 @@ class EyeProcessor:
)
else:
pass
self.det.detect(self.current_image_gray) # <- single call per frame
cx, cy = map(int, self.det.center_fine) # fine centre (upsampled)
cv2.circle(self.current_image_gray, (cx, cy), 3, (0, 0, 255), -1)
cv2.rectangle(self.current_image_gray, self.det.pupil_rect_fine, (0, 255, 0), 1)
(
self.current_image_gray,
resize_img,
self.rawx,
self.rawy,
self.radius,
) = self.er_ahsf.detect_etvr(self.current_image_gray)
self.thresh = self.current_image_gray
self.out_x, self.out_y, self.avg_velocity = cal.cal_osc(self, self.rawx, self.rawy, self.angle)
self.current_algorithm = EyeInfoOrigin.HSF
@ -599,9 +608,13 @@ class EyeProcessor:
# set algo priorities
if self.settings.gui_AHSFRAC:
if self.er_ahsf is None:
self.er_ahsf = self.AHSF
algolist[self.settings.gui_AHSFRACP] = self.AHSFRACM
if self.settings.gui_AHSF:
if self.er_ahsf is None:
self.er_ahsf = self.AHSF
algolist[self.settings.gui_AHSFP] = self.AHSFM
if self.settings.gui_HSF:

View File

@ -62,7 +62,7 @@ WINDOW_NAME = "EyeTrackApp"
page_url = "https://github.com/EyeTrackVR/EyeTrackVR/releases/latest"
appversion = "EyeTrackApp 0.2.4"
appversion = "EyeTrackApp 0.2.6"
class KeyManager:
@ -84,6 +84,7 @@ class KeyManager:
self.VRCFT_MODULE_SETTINGS_RADIO_NAME = f"-VRCFTSETTINGSRADIO{unique_id}-"
self.GUIOFF_RADIO_NAME = f"-GUIOFF{unique_id}-"
# Create an instance of the KeyManager
key_manager = KeyManager()
@ -339,12 +340,13 @@ def main():
# If we're in either mode and someone hits q, quit immediately
if event in ("Exit", sg.WIN_CLOSED) and not config.settings.gui_disable_gui:
print("\033[94m[INFO] Exiting EyeTrackApp\033[0m")
for eye in eyes:
eye.stop()
cancellation_event.set()
osc_manager.shutdown()
timerResolution(False)
print("\033[94m[INFO] Exiting EyeTrackApp\033[0m")
window.close()
os._exit(0) # I do not like this, but for now this fixes app hang on close
return
@ -460,8 +462,13 @@ def main():
window[key_manager.ALGO_SETTINGS_NAME].update(visible=False)
config.eye_display_id = EyeId.VRCFTMODULESETTINGS
config.save()
else:
else:
# Otherwise, render all
for eye in eyes:
if eye.started():
@ -487,5 +494,8 @@ def main():
window.close()
break
if __name__ == "__main__":
main()

View File

@ -37,6 +37,7 @@ import os
import subprocess
import math
from utils.calibration_3d import receive_calibration_data, converge_3d
from utils.calibration_elipse import *
from utils.misc_utils import resource_path
from pathlib import Path
@ -173,8 +174,14 @@ def overlay_calibrate_3d(self):
class cal:
def cal_osc(self, cx, cy, angle):
if self.config.calib_evecs is not None and self.config.calib_XOFF != None:
self.cal.init_from_save(self.config.calib_evecs, self.config.calib_axes)
# print(self.eye_id)
else:
if self.printcal:
print("\033[91m[ERROR] Please Calibrate Eye(s).\033[0m")
self.printcal = False
if cx == None or cy == None:
return 0, 0
@ -186,75 +193,29 @@ class cal:
flipx = self.settings.gui_flip_x_axis_right
else:
flipx = self.settings.gui_flip_x_axis_left
if self.calibration_3d_frame_counter == -621: # or self.settings.gui_3d_calibration:
self.calibration_3d_frame_counter = self.calibration_3d_frame_counter - 1
overlay_calibrate_3d(self)
self.config.calibration_points_3d = []
# print(self.eye_id, cx, cy)
# self.settings.gui_3d_calibration = False
if self.settings.grab_3d_point:
# Check if both calibrations are done
if var.left_calib and var.right_calib:
self.settings.grab_3d_point = False
var.left_calib = False
var.right_calib = False
print("end", len(self.config.calibration_points_3d), self.config.calibration_points_3d)
else:
# Check if it's the left eye and left calibration is not done yet
if self.eye_id == EyeId.LEFT and not var.left_calib:
var.left_calib = True
self.config.calibration_points_3d.append((cx, cy, 1))
# Check if it's the right eye and right calibration is not done yet
elif self.eye_id == EyeId.RIGHT and not var.right_calib:
var.right_calib = True
self.config.calibration_points_3d.append((cx, cy, 0))
if self.eye_id == EyeId.LEFT and len(self.config.calibration_points_3d) == 9 and var.left_calib == False:
var.left_calib = True
receive_calibration_data(self.config.calibration_points_3d, self.eye_id)
print("SENT LEFT EYE POINTS")
var.completed_3d_calib += 1
if self.eye_id == EyeId.RIGHT and len(self.config.calibration_points_3d) == 9 and var.right_calib == False:
var.right_calib = True
receive_calibration_data(self.config.calibration_points_3d, self.eye_id)
print("SENT RIGHT EYE POINTS")
var.completed_3d_calib += 1
# print(len(self.config.calibration_points), self.eye_id)
if var.completed_3d_calib >= 2:
converge_3d()
# pass
if self.calibration_frame_counter == 0:
self.calibration_frame_counter = None
self.config.calib_XOFF = cx
self.config.calib_YOFF = cy
self.config.calib_evecs, self.config.calib_axes = self.cal.fit_ellipse()
self.baseconfig.save()
PlaySound(resource_path("Audio/completed.wav"), SND_FILENAME | SND_ASYNC)
if self.calibration_frame_counter == self.settings.calibration_samples:
self.config.calib_XMAX = -69420
self.config.calib_XMIN = 69420
self.config.calib_YMAX = -69420
self.config.calib_YMIN = 69420
self.blink_clear = True
self.calibration_frame_counter -= 1
elif self.calibration_frame_counter != None:
self.cal.add_sample(cx, cy)
self.blink_clear = False
self.settings.gui_recenter_eyes = False
if cx > self.config.calib_XMAX:
self.config.calib_XMAX = cx
if cx < self.config.calib_XMIN:
self.config.calib_XMIN = cx
if cy > self.config.calib_YMAX:
self.config.calib_YMAX = cy
if cy < self.config.calib_YMIN:
self.config.calib_YMIN = cy
self.calibration_frame_counter -= 1
if self.settings.gui_recenter_eyes == True:
@ -273,83 +234,45 @@ class cal:
out_x = 0.5
out_y = 0.5
if self.config.calib_XMAX != None and self.config.calib_XOFF != None:
calib_diff_x_MAX = self.config.calib_XMAX - self.config.calib_XOFF
if calib_diff_x_MAX == 0:
calib_diff_x_MAX = 1
calib_diff_x_MIN = self.config.calib_XMIN - self.config.calib_XOFF
if calib_diff_x_MIN == 0:
calib_diff_x_MIN = 1
out_x, out_y = self.cal.normalize((cx, cy), (self.config.calib_XOFF, self.config.calib_YOFF))
calib_diff_y_MAX = self.config.calib_YMAX - self.config.calib_YOFF
if calib_diff_y_MAX == 0:
calib_diff_y_MAX = 1
if self.settings.gui_flip_y_axis: # check config on flipped values settings and apply accordingly
out_y = -out_y # flip
calib_diff_y_MIN = self.config.calib_YMIN - self.config.calib_YOFF
if calib_diff_y_MIN == 0:
calib_diff_y_MIN = 1
if flipx:
out_x = -out_x
xl = float((cx - self.config.calib_XOFF) / calib_diff_x_MAX)
xr = float((cx - self.config.calib_XOFF) / calib_diff_x_MIN)
yu = float((cy - self.config.calib_YOFF) / calib_diff_y_MIN)
yd = float((cy - self.config.calib_YOFF) / calib_diff_y_MAX)
if self.settings.gui_outer_side_falloff:
if self.settings.gui_flip_y_axis: # check config on flipped values settings and apply accordingly
if yd >= 0:
out_y = max(0.0, min(1.0, yd))
if yu > 0:
out_y = -abs(max(0.0, min(1.0, yu)))
run_time = time.time()
out_x_mult = out_x * 100
out_y_mult = out_y * 100
velocity = abs(
np.sqrt(abs(np.square(out_x_mult - var.past_x) - np.square(out_y_mult - var.past_y)))
/ ((var.start_time - run_time) * 10)
)
if len(var.velocity_rolling_list) < 15:
var.velocity_rolling_list.append(float(velocity))
else:
if yd >= 0:
out_y = -abs(max(0.0, min(1.0, yd)))
if yu > 0:
out_y = max(0.0, min(1.0, yu))
var.velocity_rolling_list.pop(0)
var.velocity_rolling_list.append(float(velocity))
var.average_velocity = sum(var.velocity_rolling_list) / len(var.velocity_rolling_list)
var.past_x = out_x_mult
var.past_y = out_y_mult
if flipx:
if xr >= 0:
out_x = -abs(max(0.0, min(1.0, xr)))
if xl > 0:
out_x = max(0.0, min(1.0, xl))
else:
if xr >= 0:
out_x = max(0.0, min(1.0, xr))
if xl > 0:
out_x = -abs(max(0.0, min(1.0, xl)))
out_x, out_y = velocity_falloff(self, var, out_x, out_y)
if self.settings.gui_outer_side_falloff:
try:
noisy_point = np.array([float(out_x), float(out_y)]) # fliter our values with a One Euro Filter
point_hat = self.one_euro_filter(noisy_point)
out_x = point_hat[0]
out_y = point_hat[1]
run_time = time.time()
out_x_mult = out_x * 100
out_y_mult = out_y * 100
velocity = abs(
np.sqrt(abs(np.square(out_x_mult - var.past_x) - np.square(out_y_mult - var.past_y)))
/ ((var.start_time - run_time) * 10)
)
if len(var.velocity_rolling_list) < 15:
var.velocity_rolling_list.append(float(velocity))
else:
var.velocity_rolling_list.pop(0)
var.velocity_rolling_list.append(float(velocity))
var.average_velocity = sum(var.velocity_rolling_list) / len(var.velocity_rolling_list)
var.past_x = out_x_mult
var.past_y = out_y_mult
except:
pass
out_x, out_y = velocity_falloff(self, var, out_x, out_y)
return out_x, out_y, var.average_velocity
try:
noisy_point = np.array([float(out_x), float(out_y)]) # fliter our values with a One Euro Filter
point_hat = self.one_euro_filter(noisy_point)
out_x = point_hat[0]
out_y = point_hat[1]
except:
pass
return out_x, out_y, var.average_velocity
else:
if self.printcal:
print("\033[91m[ERROR] Please Calibrate Eye(s).\033[0m")
self.printcal = False
return 0, 0, 0

View File

@ -0,0 +1,190 @@
import numpy as np
import matplotlib.pyplot as plt
class CalibrationEllipse:
def __init__(self, n_std_devs=2.5):
self.xs = []
self.ys = []
self.n_std_devs = float(n_std_devs)
self.fitted = False
self.scale_factor = 0.75
self.flip_y = False # Set to True if up/down are backwards
self.flip_x = False # Adjust if left/right are backwards
# Ellipse parameters
self.center = None # Mean pupil position (ellipse center)
self.axes = None # Semi-axes (std_dev based)
self.rotation = None # Rotation angle
self.evecs = None # Eigenvectors
def add_sample(self, x, y):
self.xs.append(float(x))
self.ys.append(float(y))
self.fitted = False
def set_inset_percent(self, percent_smaller=0.0):
clamped_percent = np.clip(percent_smaller, 0.0, 100.0)
self.scale_factor = 1.0 - (clamped_percent / 100.0)
# print(f"Set inset to {clamped_percent}%. New scale_factor: {self.scale_factor}")
def init_from_save(self, evecs, axes):
self.evecs = np.asarray(evecs, dtype=float)
self.axes = np.asarray(axes, dtype=float)
self.fitted = True
def fit_ellipse(self):
N = len(self.xs)
if N < 2:
print("Warning: Need >= 2 samples to fit PCA. Fit failed.")
self.fitted = False
return 0,0
points = np.column_stack([self.xs, self.ys])
self.center = np.mean(points, axis=0)
centered_points = points - self.center
cov = np.cov(centered_points, rowvar=False)
try:
evals_cov, evecs_cov = np.linalg.eigh(cov)
except np.linalg.LinAlgError as e:
# print(f"PCA Eigen-decomposition failed: {e}")
self.fitted = False
return 0,0
# Sort eigenvectors by alignment with screen axes (X, Y), not by magnitude
# evecs_cov[:, 0] is eigenvector for first eigenvalue, evecs_cov[:, 1] for second
# We want [0] to be X-axis aligned, [1] to be Y-axis aligned
# Determine which eigenvector is more X-aligned vs Y-aligned
x_alignment = np.abs(evecs_cov[0, :]) # How much each evec points in X direction
y_alignment = np.abs(evecs_cov[1, :]) # How much each evec points in Y direction
if x_alignment[0] > x_alignment[1]:
# evec 0 is more X-aligned, evec 1 is more Y-aligned - keep as is
self.evecs = evecs_cov
std_devs = np.sqrt(evals_cov)
else:
# evec 1 is more X-aligned, evec 0 is more Y-aligned - swap them
self.evecs = evecs_cov[:, [1, 0]]
std_devs = np.sqrt(evals_cov[[1, 0]])
self.axes = std_devs * self.n_std_devs
if self.axes[0] < 1e-12: self.axes[0] = 1e-12
if self.axes[1] < 1e-12: self.axes[1] = 1e-12
major_index = np.argmax(std_devs)
major_vec = self.evecs[:, major_index]
self.rotation = np.arctan2(major_vec[1], major_vec[0])
self.fitted = True
return self.evecs.T, self.axes
# Scale by ellipse axes (with scale factor for margins)
scaled_axes = self.axe
# print(f"Ellipse fitted: center={self.center}, axes={self.axes}, rotation={np.degrees(self.rotation):.1f}°")
def fit_and_visualize(self):
plt.figure(figsize=(10, 8))
plt.plot(self.xs, self.ys, 'k.', label='Calibration Samples', alpha=0.5, markersize=8)
plt.axis('equal')
plt.grid(True, alpha=0.3)
plt.xlabel('Pupil X (pixels)')
plt.ylabel('Pupil Y (pixels)')
if not self.fitted:
self.fit_ellipse()
if self.fitted:
scaled_axes = self.axes * self.scale_factor
t = np.linspace(0, 2 * np.pi, 200)
local_coords = np.column_stack([scaled_axes[0] * np.cos(t),
scaled_axes[1] * np.sin(t)])
world_coords = (self.evecs @ local_coords.T).T + self.center
plt.plot(world_coords[:, 0], world_coords[:, 1], 'b-',
linewidth=2, label=f'Calibration Ellipse ({self.scale_factor * 100:.0f}% scale)')
plt.plot(self.center[0], self.center[1], 'r+',
markersize=15, markeredgewidth=3, label='Ellipse Center (Mean)')
# Draw principal axes
for i, (axis_len, color, name) in enumerate([(scaled_axes[0], 'g', 'Major'),
(scaled_axes[1], 'm', 'Minor')]):
axis_vec = self.evecs[:, i] * axis_len
plt.arrow(self.center[0], self.center[1], axis_vec[0], axis_vec[1],
head_width=5, head_length=7, fc=color, ec=color, alpha=0.6,
label=f'{name} Axis')
plt.title(f'Eye Tracking Calibration Ellipse (PCA, {self.n_std_devs}σ)')
else:
plt.title("Ellipse Fit FAILED (Not enough points)")
plt.legend()
plt.tight_layout()
plt.show()
def normalize(self, pupil_pos, target_pos=None, clip=True):
if not self.fitted:
# print("ERROR: Ellipse not fitted yet. Call fit_ellipse() first.")
return 0.0, 0.0
# Current pupil position
x, y = float(pupil_pos[0]), float(pupil_pos[1])
p = np.array([x, y], dtype=float)
# Reference point (where we're measuring FROM)
# If no target specified, use ellipse center (neutral gaze position)
if target_pos is None:
reference = self.center
else:
reference = np.asarray(target_pos, dtype=float)
# Vector from reference to current pupil position
p_centered = p - reference
# Rotate into ellipse principal axes space
p_rot = self.evecs.T @ p_centered
# Scale by ellipse axes (with scale factor for margins)
scaled_axes = self.axes * self.scale_factor
scaled_axes[scaled_axes < 1e-12] = 1e-12
# Normalize: pupil offset / ellipse radius in that direction
norm = p_rot / scaled_axes
# Apply coordinate flips for eye tracking conventions
norm_x = -norm[0] if self.flip_x else norm[0]
norm_y = -norm[1] if self.flip_y else norm[1]
if clip:
norm_x = np.clip(norm_x, -1.0, 1.0)
norm_y = np.clip(norm_y, -1.0, 1.0)
return float(norm_x), float(norm_y)
def denormalize(self, norm_x, norm_y, target_pos=None):
if not self.fitted:
print("ERROR: Ellipse not fitted yet.")
return 0.0, 0.0
# Apply inverse flips
nx = -norm_x if self.flip_x else norm_x
ny = -norm_y if self.flip_y else norm_y
# Scale by ellipse axes
scaled_axes = self.axes * self.scale_factor
p_rot = np.array([nx, ny]) * scaled_axes
# Rotate back to world space
p_centered = self.evecs @ p_rot
# Add reference point
reference = self.center if target_pos is None else np.asarray(target_pos, dtype=float)
p = p_centered + reference
return float(p[0]), float(p[1])

1639
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -10,8 +10,8 @@ repository = "https://github.com/EyeTrackVR/EyeTrackVR"
python = "~3.11.0"
python-osc = "^1.8.0"
requests = "^2.28.1"
opencv-python = "~4.6.0.66"
numpy = "~1.24.0"
opencv-python = "^4.6.0.66"
numpy = "~1.24.3"
pye3d = "^0.3.2"
pysimplegui-4-foss = "^4.6.4.1"
pydantic = "^2.4.2"
@ -25,7 +25,7 @@ colorama = "^0.4.6"
taskipy = "^1.10.4"
pytest = "^8.0.0"
pytest-cov = "^4.1.0"
numba = "^0.61.2"
matplotlib = "^3.10.7"
[tool.poetry.group.dev.dependencies]
black = "^22.10.0"