mirror of
https://github.com/EyeTrackVR/EyeTrackVR.git
synced 2025-11-04 14:39:42 +08:00
Compare commits
8 Commits
58eaed9ad4
...
60fef008c8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60fef008c8 | ||
|
|
2ddae43e95 | ||
|
|
ccb8596849 | ||
|
|
ad8c86b54c | ||
|
|
a0e74e6822 | ||
|
|
6c8383ffe1 | ||
|
|
b54686923c | ||
|
|
f0a655b03b |
@ -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]")
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 = []
|
||||
|
||||
@ -36,6 +36,7 @@ class EyeId(IntEnum):
|
||||
ALGOSETTINGS = 4
|
||||
VRCFTMODULESETTINGS = 5
|
||||
GUIOFF = 6
|
||||
BSB2E = 7
|
||||
|
||||
|
||||
class EyeInfoOrigin(Enum):
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
@ -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
|
||||
|
||||
190
EyeTrackApp/utils/calibration_elipse.py
Normal file
190
EyeTrackApp/utils/calibration_elipse.py
Normal 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
1639
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user