EyeTrackVR/EyeTrackApp/config.py
2025-04-03 11:44:09 -07:00

336 lines
12 KiB
Python

"""
------------------------------------------------------------------------------------------------------
,@@@@@@
@@@@@@@@@@@ @@@
@@@@@@@@@@@@ @@@@@@@@@@@
@@@@@@@@@@@@@ @@@@@@@@@@@@@@
@@@@@@@/ ,@@@@@@@@@@@@@
/@@@@@@@@@@@@@@@ @@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@
@@@@@@@@ @@@@@
,@@@ @@@@&
@@@@@@. @@@@
@@@ @@@@@@@@@/ @@@@@
,@@@. @@@@@@((@ @@@@(
//@@@ ,, @@@@ @@@@@
@@@( @@@@@@@
@@@ @ @@@@@@@@#
@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@(
Copyright (c) 2025 EyeTrackVR <3
LICENSE: Babble Software Distribution License 1.0
------------------------------------------------------------------------------------------------------
"""
import json
import os.path
import shutil
from colorama import Fore
from pydantic import BaseModel
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"
class EyeTrackCameraConfig(BaseModel):
gui_rotation_ui_padding: bool = True
rotation_angle: int = 0
roi_window_x: int = 0
roi_window_y: int = 0
roi_window_w: int = 240
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_XOFF: Union[float, None] = None
calib_YOFF: Union[float, None] = None
calibration_points: List[List[Union[float, None]]] = []
calibration_points_3d: List[List[Union[float, None]]] = []
leap_calibration_percentile_90: float = 0
leap_calibration_percentile_2: float = 0
leap_calibrated: bool = False
def update_capture_source(self, new_camera_address: str):
if not new_camera_address:
self.capture_source = None
return
if new_camera_address.isnumeric():
self.capture_source = int(new_camera_address)
return
# we were passed an IP, probably, lets add HTTP:// to it
if len(new_camera_address) > 5 and not (
not new_camera_address.startswith(("http", "/dev")) or not new_camera_address.endswith(".mp4")
):
self.capture_source = f"http://{new_camera_address}"
return
self.capture_source = new_camera_address
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)
# no reason to update if it's the same value
if old_value == value:
return False
if hasattr(self, f"update_{key}"):
update_attr = getattr(self, f"update_{key}")
if callable(update_attr):
update_attr(value)
else:
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
gui_flip_y_axis: bool = False
gui_RANSAC3D: bool = False
gui_HSF: bool = False
gui_BLOB: bool = False
gui_BLINK: bool = False
gui_HSRAC: bool = False
gui_AHSFRAC: bool = False
gui_AHSF: bool = False
gui_DADDY: bool = False
gui_LEAP: bool = True
gui_HSF_radius: int = 15
gui_HSF_radius_left: int = 10
gui_HSF_radius_right: int = 10
gui_min_cutoff: str = "0.0004"
gui_speed_coefficient: str = "0.9"
gui_osc_address: str = "127.0.0.1"
gui_osc_port: int = 9000
gui_osc_receiver_port: int = 9001
gui_osc_recenter_address: str = "/avatar/parameters/etvr_recenter"
gui_osc_recalibrate_address: str = "/avatar/parameters/etvr_recalibrate"
gui_blob_maxsize: float = 25
gui_blob_minsize: float = 10
gui_recenter_eyes: bool = False
gui_3d_calibration: bool = False
grab_3d_point: bool = False
tracker_single_eye: int = 0
gui_threshold: int = 65
gui_AHSFRACP: int = 1
gui_AHSFP: int = 2
gui_HSRACP: int = 3
gui_HSFP: int = 4
gui_DADDYP: int = 5
gui_RANSAC3DP: int = 6
gui_BLOBP: int = 7
gui_LEAPP: int = 8
gui_IBO: bool = False
gui_skip_autoradius: bool = False
gui_thresh_add: int = 11
gui_update_check: bool = True
gui_ROSC: bool = False
gui_circular_crop_right: bool = False
gui_circular_crop_left: bool = False
ibo_filter_samples: int = 400
ibo_average_output_samples: int = 0
ibo_fully_close_eye_threshold: float = 0.3
leap_calibration_samples: int = 2000
calibration_samples: int = 600
osc_right_eye_close_address: str = "/avatar/parameters/RightEyeLidExpandedSqueeze"
osc_left_eye_close_address: str = "/avatar/parameters/LeftEyeLidExpandedSqueeze"
osc_left_eye_x_address: str = "/avatar/parameters/LeftEyeX"
osc_right_eye_x_address: str = "/avatar/parameters/RightEyeX"
osc_eyes_y_address: str = "/avatar/parameters/EyesY"
osc_eyes_pupil_dilation_address: str = "/avatar/parameters/EyesDilation"
osc_invert_eye_close: bool = False
gui_RANSACBLINK: bool = False
gui_disable_gui: bool = False
gui_right_eye_dominant: bool = False
gui_left_eye_dominant: bool = False
gui_outer_side_falloff: bool = False
gui_eye_dominant_diff_thresh: float = 0.3
gui_legacy_ransac: bool = False
gui_legacy_ransac_thresh_right: int = 80
gui_legacy_ransac_thresh_left: int = 80
gui_LEAP_lid: bool = True
gui_osc_vrcft_v1: bool = False
gui_osc_vrcft_v2: bool = False
gui_vrc_native: bool = True
gui_pupil_dilation: bool = False
gui_VRCFTModulePort: int = 8889
gui_VRCFTModuleIPAddress: str = "127.0.0.1"
gui_ShouldEmulateEyeWiden: bool = False
gui_ShouldEmulateEyeSquint: bool = False
gui_ShouldEmulateEyebrows: bool = False
gui_WidenThresholdV1_min: float = 0.60
gui_WidenThresholdV1_max: float = 1
gui_WidenThresholdV2_min: float = 0.60
gui_WidenThresholdV2_max: float = 1.05
gui_SqueezeThresholdV1_min: float = 0.07
gui_SqueezeThresholdV1_max: float = 0.5
gui_SqueezeThresholdV2_min: float = 0.07
gui_SqueezeThresholdV2_max: float = -1
gui_EyebrowThresholdRising: float = 0.8
gui_EyebrowThresholdLowering: float = 0.15
gui_OutputMultiplier: float = 1
gui_use_module: bool = False
class EyeTrackConfig(BaseModel):
version: int = 1
right_eye: EyeTrackCameraConfig = EyeTrackCameraConfig()
left_eye: EyeTrackCameraConfig = EyeTrackCameraConfig()
settings: EyeTrackSettingsConfig = EyeTrackSettingsConfig()
eye_display_id: EyeId = EyeId.RIGHT
__listeners = []
@staticmethod
def load():
if not os.path.exists(CONFIG_FILE_NAME):
print("No settings file, using base settings")
return EyeTrackConfig()
try:
with open(CONFIG_FILE_NAME, "r") as settings_file:
return EyeTrackConfig(**json.load(settings_file))
except json.JSONDecodeError:
print("[INFO] Failed to load settings file")
load_config = None
if os.path.exists(BACKUP_CONFIG_FILE_NAME):
try:
with open(BACKUP_CONFIG_FILE_NAME, "r") as settings_file:
load_config = EyeTrackConfig(**json.load(settings_file))
print("[INFO] Using backup settings")
except json.JSONDecodeError:
pass
if load_config is None:
print("[INFO] using base settings")
load_config = EyeTrackConfig()
return load_config
def validate_camera_address_conflict(self, eye_id, capture_source):
match eye_id:
case EyeId.RIGHT:
if self.left_eye.capture_source == capture_source:
print(
f"{Fore.YELLOW}[WARN] Capture source {capture_source} already in use by the left camera.{Fore.RESET}"
)
return False
case EyeId.LEFT:
if self.right_eye.capture_source == capture_source:
print(
f"{Fore.YELLOW}[WARN] Capture source {capture_source} already in use by the right camera.{Fore.RESET}"
)
return False
case _:
return False
return True
def update_eye_model_config(self, eye_id: EyeId, data: dict, should_save=True, should_notify=True) -> bool:
"""
A more granular method for updating a particular model so that everything that relies on it
will get notified about any changes. Note, it acts a bit like pub-sub,
we don't care what changes got passed, we will notify the listeners with them.
It's the listeners job to check if they want that update.
"""
# The app really doesn't like address clashes, so we have to validate it as soon as possible
# otherwise we crash
if "capture_source" in data and not self.validate_camera_address_conflict(eye_id, data["capture_source"]):
return False
match eye_id:
case EyeId.RIGHT:
changed = self.right_eye.update(data)
case EyeId.LEFT:
changed = self.left_eye.update(data)
case _:
return False
if should_save:
self.save()
if should_notify:
self.__notify_listeners(data)
return changed
def update(self, data, save=False):
"""
More of an internal method for modules to be able to update the config
and have other parts of the system react to changes
"""
for field, value in data.items():
setattr(self.settings, field, value)
self.__notify_listeners(data)
if save:
self.save()
def save(self):
# make sure this is only called if there is a change
if os.path.exists(CONFIG_FILE_NAME):
try:
# Verify existing configuration files.
with open(CONFIG_FILE_NAME, "r") as settings_file:
EyeTrackConfig(**json.load(settings_file))
shutil.copy(CONFIG_FILE_NAME, BACKUP_CONFIG_FILE_NAME)
# print("Backed up settings files.") # Comment out because it's too loud.
except shutil.SameFileError:
pass
except json.JSONDecodeError:
# No backup because the saved settings file is broken.
pass
with open(CONFIG_FILE_NAME, "w") as settings_file:
json.dump(obj=self.model_dump(warnings=False), fp=settings_file)
print(f"\033[92m[INFO] Config Saved Successfully\033[0m")
def register_listener_callback(self, callback):
print(f"[DEBUG] Registering listener {callback}")
self.__listeners.append(callback)
def __notify_listeners(self, data: dict):
for listener in self.__listeners:
listener(data)