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._img_boundary = (0, 0, 0, 0)
self._init_rect_down = (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]]: def detect(self, img_gray: np.ndarray) -> Tuple[Tuple[int, int, int, int], Tuple[float, float]]:
if img_gray.dtype != np.uint8: if img_gray.dtype != np.uint8:
raise TypeError("img_gray must be uint8 [0,255]") 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! ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
#define MyAppName "EyeTrackVR" #define MyAppName "EyeTrackVR"
#define MyAppVersion "0.2.2" #define MyAppVersion "0.2.4"
#define MyAppPublisher "EyeTrackVR" #define MyAppPublisher "EyeTrackVR"
#define MyAppURL "https://redhawk989.github.io/EyeTrackVR/" #define MyAppURL "https://redhawk989.github.io/EyeTrackVR/"
#define MyAppExeName "eyetrackapp.exe" #define MyAppExeName "eyetrackapp.exe"

View File

@ -27,18 +27,21 @@ LICENSE: Babble Software Distribution License 1.0
import json import json
import os.path import os.path
import shutil import shutil
import numpy as np
from colorama import Fore from colorama import Fore
from pydantic import BaseModel from pydantic import BaseModel, field_validator
from typing import Any, Union, List from typing import Any, Union, List
import os import os
from eye import EyeId from eye import EyeId
CONFIG_FILE_NAME: str = "eyetrack_settings.json" CONFIG_FILE_NAME: str = "eyetrack_settings.json"
BACKUP_CONFIG_FILE_NAME: str = "eyetrack_settings.backup" 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): class EyeTrackCameraConfig(BaseModel):
gui_rotation_ui_padding: bool = True gui_rotation_ui_padding: bool = True
rotation_angle: int = 0 rotation_angle: int = 0
@ -48,10 +51,9 @@ class EyeTrackCameraConfig(BaseModel):
roi_window_h: int = 240 roi_window_h: int = 240
focal_length: int = 30 focal_length: int = 30
capture_source: Union[int, str, None] = None capture_source: Union[int, str, None] = None
calib_XMAX: Union[float, None] = None calib_axes: Union[List[float], None] = None
calib_XMIN: Union[float, None] = None calib_evecs: Union[List[List[float]], None] = None
calib_YMAX: Union[float, None] = None calib_center: Union[List[float], None] = None
calib_YMIN: Union[float, None] = None
calib_XOFF: Union[float, None] = None calib_XOFF: Union[float, None] = None
calib_YOFF: Union[float, None] = None calib_YOFF: Union[float, None] = None
calibration_points: List[List[Union[float, None]]] = [] calibration_points: List[List[Union[float, None]]] = []
@ -60,6 +62,58 @@ class EyeTrackCameraConfig(BaseModel):
leap_calibration_percentile_2: float = 0 leap_calibration_percentile_2: float = 0
leap_calibrated: bool = False 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): def update_capture_source(self, new_camera_address: str):
if not new_camera_address: if not new_camera_address:
@ -82,29 +136,6 @@ class EyeTrackCameraConfig(BaseModel):
def update(self, data: dict[str, Any]) -> bool: def update(self, data: dict[str, Any]) -> bool:
""" """
Updates the model one field at a time based on the provided data dict. 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(): for key, value in data.items():
old_value = getattr(self, key, None) old_value = getattr(self, key, None)
@ -117,13 +148,12 @@ class EyeTrackCameraConfig(BaseModel):
if callable(update_attr): if callable(update_attr):
update_attr(value) update_attr(value)
else: else:
setattr(self, "key", value) setattr(self, key, value)
return True return True
else: else:
print(f"\033[93m[WARN] Field {key} does not exist on {self}.\033[0m") print(f"\033[93m[WARN] Field {key} does not exist on {self}.\033[0m")
return False return False
class EyeTrackSettingsConfig(BaseModel): class EyeTrackSettingsConfig(BaseModel):
gui_flip_x_axis_left: bool = False gui_flip_x_axis_left: bool = False
gui_flip_x_axis_right: bool = False gui_flip_x_axis_right: bool = False
@ -222,6 +252,7 @@ class EyeTrackConfig(BaseModel):
version: int = 1 version: int = 1
right_eye: EyeTrackCameraConfig = EyeTrackCameraConfig() right_eye: EyeTrackCameraConfig = EyeTrackCameraConfig()
left_eye: EyeTrackCameraConfig = EyeTrackCameraConfig() left_eye: EyeTrackCameraConfig = EyeTrackCameraConfig()
bsb2e: EyeTrackCameraConfig = EyeTrackCameraConfig() # should we do independent per bsb eye?
settings: EyeTrackSettingsConfig = EyeTrackSettingsConfig() settings: EyeTrackSettingsConfig = EyeTrackSettingsConfig()
eye_display_id: EyeId = EyeId.RIGHT eye_display_id: EyeId = EyeId.RIGHT
__listeners = [] __listeners = []

View File

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

View File

@ -49,6 +49,9 @@ from intensity_based_openness import *
from ellipse_based_pupil_dilation import * from ellipse_based_pupil_dilation import *
from AHSF import * from AHSF import *
from osc.OSCMessage import OSCMessageType, OSCMessage from osc.OSCMessage import OSCMessageType, OSCMessage
from utils.calibration_elipse import *
os.environ["OMP_NUM_THREADS"] = "1" os.environ["OMP_NUM_THREADS"] = "1"
sys.path.append(".") sys.path.append(".")
@ -165,7 +168,9 @@ class EyeProcessor:
self.pupil_height = 0.0 self.pupil_height = 0.0
self.avg_velocity = 0.0 self.avg_velocity = 0.0
self.angle = 621 self.angle = 621
self.det = PupilDetectorHaar(ratio_outer=1.4, kf=1.4) self.er_ahsf = None
self.cal = CalibrationEllipse()
self.AHSF = PupilDetectorHaar()
try: try:
@ -238,7 +243,7 @@ class EyeProcessor:
borderValue=(255, 255, 255), 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 # 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)) 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) 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 pass
self.hasrac_en = True 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.thresh = resize_img
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.rawx, self.rawx,
self.rawy, self.rawy,
@ -520,11 +527,13 @@ class EyeProcessor:
) )
else: else:
pass pass
(
self.det.detect(self.current_image_gray) # <- single call per frame self.current_image_gray,
cx, cy = map(int, self.det.center_fine) # fine centre (upsampled) resize_img,
cv2.circle(self.current_image_gray, (cx, cy), 3, (0, 0, 255), -1) self.rawx,
cv2.rectangle(self.current_image_gray, self.det.pupil_rect_fine, (0, 255, 0), 1) self.rawy,
self.radius,
) = self.er_ahsf.detect_etvr(self.current_image_gray)
self.thresh = 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.out_x, self.out_y, self.avg_velocity = cal.cal_osc(self, self.rawx, self.rawy, self.angle)
self.current_algorithm = EyeInfoOrigin.HSF self.current_algorithm = EyeInfoOrigin.HSF
@ -599,9 +608,13 @@ class EyeProcessor:
# set algo priorities # set algo priorities
if self.settings.gui_AHSFRAC: if self.settings.gui_AHSFRAC:
if self.er_ahsf is None:
self.er_ahsf = self.AHSF
algolist[self.settings.gui_AHSFRACP] = self.AHSFRACM algolist[self.settings.gui_AHSFRACP] = self.AHSFRACM
if self.settings.gui_AHSF: if self.settings.gui_AHSF:
if self.er_ahsf is None:
self.er_ahsf = self.AHSF
algolist[self.settings.gui_AHSFP] = self.AHSFM algolist[self.settings.gui_AHSFP] = self.AHSFM
if self.settings.gui_HSF: if self.settings.gui_HSF:

View File

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

View File

@ -37,6 +37,7 @@ import os
import subprocess import subprocess
import math import math
from utils.calibration_3d import receive_calibration_data, converge_3d from utils.calibration_3d import receive_calibration_data, converge_3d
from utils.calibration_elipse import *
from utils.misc_utils import resource_path from utils.misc_utils import resource_path
from pathlib import Path from pathlib import Path
@ -173,8 +174,14 @@ def overlay_calibrate_3d(self):
class cal: class cal:
def cal_osc(self, cx, cy, angle): 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: if cx == None or cy == None:
return 0, 0 return 0, 0
@ -186,75 +193,29 @@ class cal:
flipx = self.settings.gui_flip_x_axis_right flipx = self.settings.gui_flip_x_axis_right
else: else:
flipx = self.settings.gui_flip_x_axis_left 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: if self.calibration_frame_counter == 0:
self.calibration_frame_counter = None self.calibration_frame_counter = None
self.config.calib_XOFF = cx self.config.calib_XOFF = cx
self.config.calib_YOFF = cy self.config.calib_YOFF = cy
self.config.calib_evecs, self.config.calib_axes = self.cal.fit_ellipse()
self.baseconfig.save() self.baseconfig.save()
PlaySound(resource_path("Audio/completed.wav"), SND_FILENAME | SND_ASYNC) PlaySound(resource_path("Audio/completed.wav"), SND_FILENAME | SND_ASYNC)
if self.calibration_frame_counter == self.settings.calibration_samples: 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.blink_clear = True
self.calibration_frame_counter -= 1 self.calibration_frame_counter -= 1
elif self.calibration_frame_counter != None: elif self.calibration_frame_counter != None:
self.cal.add_sample(cx, cy)
self.blink_clear = False self.blink_clear = False
self.settings.gui_recenter_eyes = 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 self.calibration_frame_counter -= 1
if self.settings.gui_recenter_eyes == True: if self.settings.gui_recenter_eyes == True:
@ -273,83 +234,45 @@ class cal:
out_x = 0.5 out_x = 0.5
out_y = 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 out_x, out_y = self.cal.normalize((cx, cy), (self.config.calib_XOFF, self.config.calib_YOFF))
if calib_diff_x_MIN == 0:
calib_diff_x_MIN = 1
calib_diff_y_MAX = self.config.calib_YMAX - self.config.calib_YOFF if self.settings.gui_flip_y_axis: # check config on flipped values settings and apply accordingly
if calib_diff_y_MAX == 0: out_y = -out_y # flip
calib_diff_y_MAX = 1
calib_diff_y_MIN = self.config.calib_YMIN - self.config.calib_YOFF if flipx:
if calib_diff_y_MIN == 0: out_x = -out_x
calib_diff_y_MIN = 1
xl = float((cx - self.config.calib_XOFF) / calib_diff_x_MAX) if self.settings.gui_outer_side_falloff:
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_flip_y_axis: # check config on flipped values settings and apply accordingly run_time = time.time()
if yd >= 0: out_x_mult = out_x * 100
out_y = max(0.0, min(1.0, yd)) out_y_mult = out_y * 100
if yu > 0: velocity = abs(
out_y = -abs(max(0.0, min(1.0, yu))) 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: else:
if yd >= 0: var.velocity_rolling_list.pop(0)
out_y = -abs(max(0.0, min(1.0, yd))) var.velocity_rolling_list.append(float(velocity))
if yu > 0: var.average_velocity = sum(var.velocity_rolling_list) / len(var.velocity_rolling_list)
out_y = max(0.0, min(1.0, yu)) var.past_x = out_x_mult
var.past_y = out_y_mult
if flipx: out_x, out_y = velocity_falloff(self, var, out_x, out_y)
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)))
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() except:
out_x_mult = out_x * 100 pass
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
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 = "~3.11.0"
python-osc = "^1.8.0" python-osc = "^1.8.0"
requests = "^2.28.1" requests = "^2.28.1"
opencv-python = "~4.6.0.66" opencv-python = "^4.6.0.66"
numpy = "~1.24.0" numpy = "~1.24.3"
pye3d = "^0.3.2" pye3d = "^0.3.2"
pysimplegui-4-foss = "^4.6.4.1" pysimplegui-4-foss = "^4.6.4.1"
pydantic = "^2.4.2" pydantic = "^2.4.2"
@ -25,7 +25,7 @@ colorama = "^0.4.6"
taskipy = "^1.10.4" taskipy = "^1.10.4"
pytest = "^8.0.0" pytest = "^8.0.0"
pytest-cov = "^4.1.0" pytest-cov = "^4.1.0"
numba = "^0.61.2" matplotlib = "^3.10.7"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
black = "^22.10.0" black = "^22.10.0"