diff --git a/.gitignore b/.gitignore index d6ad2dd..c4fbf3d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ EyeTrackApp/IBO_RIGHT.png /IBO_LEFT.png /eyetrack_settings.backup /eyetrack_settings.json +zBuild.bat diff --git a/EyeTrackApp/config.py b/EyeTrackApp/config.py index 846f779..42c26f0 100644 --- a/EyeTrackApp/config.py +++ b/EyeTrackApp/config.py @@ -222,6 +222,13 @@ class EyeTrackSettingsConfig(BaseModel): gui_eyetune_maxout: float = 1 gui_eyetune_maxup: float = 1 gui_eyetune_maxdown: float = 1 + #SmartInversionTracking + gui_smartinversion_enabled: bool = False + gui_smartinversion_select_right: bool = True + gui_smartinversion_thresh: float = 0.4 + gui_smartinversion_frame_count: int = 30 + gui_smartinversion_smoothing_rate: float = 0.025 + gui_smartinversion_minthresh: float = 0.3 class EyeTrackConfig(BaseModel): version: int = 1 diff --git a/EyeTrackApp/osc_calibrate_filter.py b/EyeTrackApp/osc_calibrate_filter.py index eabfee8..ac904d8 100644 --- a/EyeTrackApp/osc_calibrate_filter.py +++ b/EyeTrackApp/osc_calibrate_filter.py @@ -29,6 +29,7 @@ import time from enum import IntEnum from utils.misc_utils import PlaySound, SND_FILENAME, SND_ASYNC, resource_path from utils.eye_falloff import velocity_falloff +from utils.smart_inversion import smart_inversion import socket import struct import threading @@ -336,7 +337,10 @@ class cal: var.past_x = out_x_mult var.past_y = out_y_mult - out_x, out_y = velocity_falloff(self, var, out_x, out_y) + if(self.settings.gui_smartinversion_enabled): + out_x, out_y = smart_inversion(self,var, out_x, out_y) + else: + out_x, out_y = velocity_falloff(self, var, out_x, out_y) #Clamps the right eye's X values if self.eye_id == EyeId.LEFT: diff --git a/EyeTrackApp/settings/general_settings_widget.py b/EyeTrackApp/settings/general_settings_widget.py index f7fb301..dc2dea2 100644 --- a/EyeTrackApp/settings/general_settings_widget.py +++ b/EyeTrackApp/settings/general_settings_widget.py @@ -32,6 +32,7 @@ from settings.modules.GeneralSettingsModule import GeneralSettingsModule from settings.modules.OneEuroSettingsModule import OneEuroSettingsModule from settings.modules.OSCSettingsModule import OSCSettingsModule from settings.modules.EyeTuneSettingsModule import EyeTuneSettingsModule +from settings.modules.SmartInversionSettingsModule import SmartInversionSettingsModule class SettingsWidget(BaseSettingsWidget): @@ -39,6 +40,7 @@ class SettingsWidget(BaseSettingsWidget): settings_modules = [ GeneralSettingsModule, OneEuroSettingsModule, + SmartInversionSettingsModule, OSCSettingsModule, EyeTuneSettingsModule, ] diff --git a/EyeTrackApp/settings/modules/CommonFieldValidators.py b/EyeTrackApp/settings/modules/CommonFieldValidators.py index b42dab5..c15bdaf 100644 --- a/EyeTrackApp/settings/modules/CommonFieldValidators.py +++ b/EyeTrackApp/settings/modules/CommonFieldValidators.py @@ -32,3 +32,12 @@ def check_is_ip_address(v: str): return v except ValueError: raise ValueError("Please provide a valid IP Address") + +def try_convert_to_int(v: str): + """" + Checks if value provided can be converted to an integer and returns the converted result + """ + try: + return int(v) + except ValueError: + raise ValueError("Please provide a number with no decimal points") \ No newline at end of file diff --git a/EyeTrackApp/settings/modules/SmartInversionSettingsModule.py b/EyeTrackApp/settings/modules/SmartInversionSettingsModule.py new file mode 100644 index 0000000..464c21c --- /dev/null +++ b/EyeTrackApp/settings/modules/SmartInversionSettingsModule.py @@ -0,0 +1,110 @@ +from pydantic import AfterValidator +from typing_extensions import Annotated + +from settings.modules.BaseModule import BaseSettingsModule, BaseValidationModel +from settings.constants import BACKGROUND_COLOR +import PySimpleGUI as sg + +from settings.modules.CommonFieldValidators import try_convert_to_float +from settings.modules.CommonFieldValidators import try_convert_to_int + +class SmartInversionValidationModule(BaseValidationModel): + gui_smartinversion_enabled: bool + gui_smartinversion_select_right: bool + gui_smartinversion_thresh: Annotated[float, AfterValidator(try_convert_to_float)] + gui_smartinversion_frame_count: Annotated[int, AfterValidator(try_convert_to_int)] + gui_smartinversion_smoothing_rate: Annotated[float, AfterValidator(try_convert_to_float)] + gui_smartinversion_minthresh: Annotated[float, AfterValidator(try_convert_to_float)] + +class SmartInversionSettingsModule(BaseSettingsModule): + def __init__(self, config, widget_id, **kwargs): + super().__init__(config=config, widget_id=widget_id, **kwargs) + self.validation_model = SmartInversionValidationModule + self.gui_smartinversion_enabled = f"-gui_smartinversion_enabled{widget_id}-" + self.gui_smartinversion_select_right = f"-gui_smartinversion_select_right{widget_id}-" + self.gui_smartinversion_thresh = f"-gui_smartinversion_thresh{widget_id}-" + self.gui_smartinversion_frame_count =f"-gui_smartinversion_frame_count{widget_id}" + self.gui_smartinversion_smoothing_rate =f"-gui_smartinversion_smoothing_rate{widget_id}" + self.gui_smartinversion_minthresh =f"-gui_smartinversion_minthresh{widget_id}" + + + + def get_layout(self): + return [ + [ + sg.Text("Smart Inversion Tracking System:", background_color='#242224'), + ], + [ + sg.Checkbox( + "Enable:", + default=self.config.gui_smartinversion_enabled, + key=self.gui_smartinversion_enabled, + background_color="#424042", + tooltip="Enables Smart Inversion Tracking System", + ), + + sg.Radio( + "Use Left Eye", + "smartinversion_selectedeye", + background_color="#424042", + tooltip="Uses the left eye as the tracked eye.", + ), + + sg.Radio( + "Use Right Eye", + "smartinversion_selectedeye", + default=self.config.gui_smartinversion_select_right, + key=self.gui_smartinversion_select_right, + background_color="#424042", + tooltip="Uses the right eye as the tracked eye.", + ) + ], + [ + sg.Text("Max. X-Axis Difference", background_color=BACKGROUND_COLOR), + sg.InputText( + self.config.gui_smartinversion_thresh, + key=self.gui_smartinversion_thresh, + size=(0, 10), + tooltip=( + "Sets the maximum allowed difference in eye position (x-axis) to determine if the eyes are cross-eyed or not." + "\n Lower value will make cross-eye detection more sensitive." + ) + ), + ], + [ + sg.Text("Inwards Look Threshold", background_color=BACKGROUND_COLOR), + sg.InputText( + self.config.gui_smartinversion_minthresh, + key=self.gui_smartinversion_minthresh, + size=(0, 10), + tooltip=( + "Sets the minimum distance of looking in that's required before state can chaned to cross-eyed." + "\n Lower value will make cross-eye detection more sensitive." + ) + ), + ], + [ + sg.Text("Inversion Trigger Frame Count", background_color=BACKGROUND_COLOR), + sg.InputText( + self.config.gui_smartinversion_frame_count, + key=self.gui_smartinversion_frame_count, + size=(0, 10), + tooltip=( + "How long it takes to detect you are cross-eyed, or no longer cross-eyed." + "\n Higher number means longer duration before changing in or out of being cross-eyed state." + ) + ), + ], + [ + sg.Text("Smoothing Decay Rate", background_color=BACKGROUND_COLOR), + sg.InputText( + self.config.gui_smartinversion_smoothing_rate, + key=self.gui_smartinversion_smoothing_rate, + size=(0, 10), + tooltip=( + "How quickly eye smoothing decays when you enter or leave a cross-eyed state." + "\nHigher number = shorter smoothing duration." + ) + ), + ], + ] \ No newline at end of file diff --git a/EyeTrackApp/utils/smart_inversion.py b/EyeTrackApp/utils/smart_inversion.py new file mode 100644 index 0000000..628d39b --- /dev/null +++ b/EyeTrackApp/utils/smart_inversion.py @@ -0,0 +1,91 @@ +from eye import EyeId +from utils.misc_utils import clamp + +def smart_inversion(self, var, out_x, out_y): + + #Checks to see if the class already has frame counts, inversion attributes or smoothing attributes + if not hasattr(self, "smartinversion_inverted_frame_count"): + self.smartinversion_inverted_frame_count = 0 + if not hasattr(self, "smartinversion_normal_frame_count"): + self.smartinversion_normal_frame_count = 0 + if not hasattr(self, "smartinversion_is_inverted"): + self.smartinversion_is_inverted = False + if not hasattr(self, "smartinversion_smoothing_progress"): + self.smartinversion_smoothing_progress = 0 + if not hasattr(self, "smartinversion_smoothed_eye_x"): + self.smartinversion_smoothed_eye_x = 0.0 + if not hasattr(self, "smartinversion_previous_inversion_state"): + self.smartinversion_previous_inversion_state = False + + #Updates eye positions with latest + if self.eye_id == EyeId.LEFT: + var.l_eye_x = out_x + var.left_y = out_y + + if self.eye_id == EyeId.RIGHT: + var.r_eye_x = out_x + var.right_y = out_y + + #Checks if eyes are inverted, and then activates inversion if the conditions have been true for a specified number of frames. + if (var.l_eye_x > 0 and var.r_eye_x < 0) and (abs(var.l_eye_x - var.r_eye_x) > self.settings.gui_smartinversion_thresh): + self.smartinversion_inverted_frame_count = min(self.smartinversion_inverted_frame_count + 1, self.settings.gui_smartinversion_frame_count) + + if self.smartinversion_inverted_frame_count == self.settings.gui_smartinversion_frame_count: + if not self.smartinversion_is_inverted: + self.smartinversion_is_inverted = True + self.smartinversion_normal_frame_count = 0 + print(f"Inversion Activated") + + #Checks if the eyes are no longer inverted, and then clears inversion if the conditions haven't been true for a specified number of frames. + elif self.smartinversion_is_inverted and ( + not (var.l_eye_x > self.settings.gui_smartinversion_minthresh and var.r_eye_x < -self.settings.gui_smartinversion_minthresh) or + abs(var.l_eye_x - var.r_eye_x) <= self.settings.gui_smartinversion_thresh + ): + + self.smartinversion_normal_frame_count = min(self.smartinversion_normal_frame_count + 1, self.settings.gui_smartinversion_frame_count) + + if self.smartinversion_normal_frame_count == self.settings.gui_smartinversion_frame_count: + if self.smartinversion_is_inverted: + self.smartinversion_is_inverted = False + self.smartinversion_inverted_frame_count = 0 + print(f"Inversion Cleared") + + #Checks if the inversion state has recently been toggled, and activates smoothing + if self.smartinversion_previous_inversion_state != self.smartinversion_is_inverted: + self.smartinversion_smoothing_progress = 1 + self.smartinversion_previous_inversion_state = self.smartinversion_is_inverted + + #Determines which eye is being tracked based off selection and sets values accordingly + if self.settings.gui_smartinversion_select_right: + tracked_eye_x = var.r_eye_x + tracked_eye_y = var.right_y + recessive_eye = EyeId.LEFT + dominant_eye = EyeId.RIGHT + else: + tracked_eye_x = var.l_eye_x + tracked_eye_y = var.left_y + recessive_eye = EyeId.RIGHT + dominant_eye = EyeId.LEFT + + out_x = tracked_eye_x + out_y = tracked_eye_y + + #Logic if smoothing is activated + if self.smartinversion_smoothing_progress > 0: + smartinversion_lerp_factor = (1 - self.smartinversion_smoothing_progress) + + if self.smartinversion_is_inverted and self.eye_id == recessive_eye: + self.smartinversion_smoothed_eye_x += (-tracked_eye_x - self.smartinversion_smoothed_eye_x) * smartinversion_lerp_factor + else: + self.smartinversion_smoothed_eye_x += (tracked_eye_x - self.smartinversion_smoothed_eye_x) * smartinversion_lerp_factor + + self.smartinversion_smoothing_progress = max(self.smartinversion_smoothing_progress - self.settings.gui_smartinversion_smoothing_rate, 0) + out_x = self.smartinversion_smoothed_eye_x + + #Logic if inversion is active, but smoothing is not active + elif self.smartinversion_is_inverted and self.eye_id == recessive_eye: + out_x = -tracked_eye_x + + out_y = tracked_eye_y + + return out_x, out_y diff --git a/conftest.py b/conftest.py index ede52e2..6eec4f5 100644 --- a/conftest.py +++ b/conftest.py @@ -80,6 +80,13 @@ def eyetrack_settings_config(): gui_eyetune_maxout=1, gui_eyetune_maxup=1, gui_eyetune_maxdown=1, + #Smart Inversion Tracking + gui_smartinversion_enabled=False, + gui_smartinversion_select_right=True, + gui_smartinversion_thresh=0.4, + gui_smartinversion_frame_count=10, + gui_smartinversion_smoothing_rate=0.025, + gui_smartinversion_minthresh=0.3, )