diff --git a/Makefile b/Makefile index 6c16178..22153a5 100644 --- a/Makefile +++ b/Makefile @@ -10,17 +10,17 @@ JS_SRC = web/src/eyegestures.js MIN = $(JS_SRC:.js=.min.js) format: - $(FORMAT_TOOLS) python -m isort eyeGestures - $(FORMAT_TOOLS) python -m black eyeGestures + $(FORMAT_TOOLS) python -m isort eye_gestures eyeGestures + $(FORMAT_TOOLS) python -m black eye_gestures eyeGestures format_check: - $(CHECK_TOOLS) python -m isort eyeGestures --check - $(CHECK_TOOLS) python -m black eyeGestures --check + $(CHECK_TOOLS) python -m isort eye_gestures eyeGestures --check + $(CHECK_TOOLS) python -m black eye_gestures eyeGestures --check check: format_check - $(CHECK_TOOLS) python -m pylint eyeGestures - $(CHECK_TOOLS) python -m flake8 eyeGestures - $(CHECK_TOOLS) python -m mypy eyeGestures + $(CHECK_TOOLS) python -m pylint eye_gestures eyeGestures + $(CHECK_TOOLS) python -m flake8 eye_gestures eyeGestures + $(CHECK_TOOLS) python -m mypy eye_gestures eyeGestures # Web build min: @@ -61,4 +61,4 @@ test: ${PYTHON} -m unittest discover -s tests -p 'test_*.py'; \ else \ echo "No test files found in tests/; skipping test execution."; \ - fi \ No newline at end of file + fi diff --git a/README.md b/README.md index c3588a4..4bb4fa6 100644 --- a/README.md +++ b/README.md @@ -104,8 +104,8 @@ gestures.start(); ##### Python ```python -from eyeGestures.utils import VideoCapture -from eyeGestures import EyeGestures_v4 +from eye_gestures.utils import VideoCapture +from eye_gestures import EyeGestures_v4 # Initialize gesture engine and video capture gestures = EyeGestures_v3() diff --git a/examples/legacy/PupilLabDry.py b/examples/legacy/PupilLabDry.py index 879d217..28b8aa8 100644 --- a/examples/legacy/PupilLabDry.py +++ b/examples/legacy/PupilLabDry.py @@ -1,5 +1,5 @@ -from eyeGestures.utils import VideoCapture -from eyeGestures.eyegestures import EyeGestures +from eye_gestures.utils import VideoCapture +from eye_gestures.eyegestures import EyeGestures from screeninfo import get_monitors if __name__ == '__main__': diff --git a/examples/legacy/display_calibration.py b/examples/legacy/display_calibration.py index 49e06dd..99be1f5 100644 --- a/examples/legacy/display_calibration.py +++ b/examples/legacy/display_calibration.py @@ -7,8 +7,8 @@ dir_path = os.path.dirname(os.path.realpath(__file__)) sys.path.append(f'{dir_path}/..') -from eyeGestures.utils import VideoCapture -from eyeGestures.eyegestures import EyeGestures_v2 +from eye_gestures.utils import VideoCapture +from eye_gestures.eyegestures import EyeGestures_v2 gestures = EyeGestures_v2() gestures.uploadCalibrationMap([[0,0],[0,1],[1,0],[1,1]]) diff --git a/examples/legacy/simple_example.py b/examples/legacy/simple_example.py index d9db98b..920dd17 100644 --- a/examples/legacy/simple_example.py +++ b/examples/legacy/simple_example.py @@ -7,8 +7,8 @@ dir_path = os.path.dirname(os.path.realpath(__file__)) sys.path.append(f'{dir_path}/..') -from eyeGestures.utils import VideoCapture -from eyeGestures.eyegestures import EyeGestures_v1 +from eye_gestures.utils import VideoCapture +from eye_gestures.eyegestures import EyeGestures_v1 gestures = EyeGestures_v1(285,115,80,15) diff --git a/examples/simple_example_v2.py b/examples/simple_example_v2.py index d066bf7..385e04b 100644 --- a/examples/simple_example_v2.py +++ b/examples/simple_example_v2.py @@ -22,8 +22,8 @@ dir_path = os.path.dirname(os.path.realpath(__file__)) sys.path.append(f'{dir_path}/..') -from eyeGestures.utils import VideoCapture -from eyeGestures import EyeGestures_v2 +from eye_gestures.utils import VideoCapture +from eye_gestures import EyeGestures_v2 gestures = EyeGestures_v2() cap = VideoCapture(0) diff --git a/examples/simple_example_v2_fast.py b/examples/simple_example_v2_fast.py index 71d44ed..5a691ed 100644 --- a/examples/simple_example_v2_fast.py +++ b/examples/simple_example_v2_fast.py @@ -22,8 +22,8 @@ dir_path = os.path.dirname(os.path.realpath(__file__)) sys.path.append(f'{dir_path}/..') -from eyeGestures.utils import VideoCapture -from eyeGestures import EyeGestures_v2 +from eye_gestures.utils import VideoCapture +from eye_gestures import EyeGestures_v2 gestures = EyeGestures_v2() cap = VideoCapture(0) diff --git a/examples/simple_example_v3.py b/examples/simple_example_v3.py index 1198d92..ceb3d9a 100644 --- a/examples/simple_example_v3.py +++ b/examples/simple_example_v3.py @@ -22,8 +22,8 @@ dir_path = os.path.dirname(os.path.realpath(__file__)) sys.path.append(f'{dir_path}/..') -from eyeGestures.utils import VideoCapture -from eyeGestures import EyeGestures_v3 +from eye_gestures.utils import VideoCapture +from eye_gestures import EyeGestures_v3 gestures = EyeGestures_v3() cap = VideoCapture(0) diff --git a/examples/simple_example_v4.py b/examples/simple_example_v4.py index d7db1b0..3887646 100644 --- a/examples/simple_example_v4.py +++ b/examples/simple_example_v4.py @@ -22,8 +22,8 @@ dir_path = os.path.dirname(os.path.realpath(__file__)) sys.path.append(f'{dir_path}/..') -from eyeGestures.utils import VideoCapture -from eyeGestures import EyeGestures_v4 +from eye_gestures.utils import VideoCapture +from eye_gestures import EyeGestures_v4 gestures = EyeGestures_v4() cap = VideoCapture(0) diff --git a/examples/troubleshooting_camera.py b/examples/troubleshooting_camera.py index 091cecf..fd0cc8f 100644 --- a/examples/troubleshooting_camera.py +++ b/examples/troubleshooting_camera.py @@ -16,7 +16,7 @@ screen = pygame.display.set_mode((screen_width, screen_height)) pygame.display.set_caption("EyeGestures v3 example") -from eyeGestures.utils import VideoCapture +from eye_gestures.utils import VideoCapture cap = VideoCapture(0) diff --git a/eyeGestures/__init__.py b/eyeGestures/__init__.py index d550605..2b51ab5 100644 --- a/eyeGestures/__init__.py +++ b/eyeGestures/__init__.py @@ -1,138 +1,36 @@ -from typing import Any, Dict, List, Optional, Tuple - -import cv2 -import numpy as np -import numpy.typing as npt - -from eyeGestures.face import Face, FaceFinder -from eyeGestures.Fixation import Fixation -from eyeGestures.utils import recoverable - -try: - from .EyegesturesEngine import EyeGesturesEnginePython as RustEyeGesturesEngine # type: ignore[import-untyped] -except ImportError: +"""Deprecated alias for :mod:`eye_gestures`. + +The module was renamed from ``eyeGestures`` to ``eye_gestures``. Importing it +under the old name still works but emits a :class:`DeprecationWarning`. +""" + +import importlib +import pkgutil +import sys +import warnings + +warnings.warn( + "the 'eyeGestures' package has been renamed to 'eye_gestures'; " + "importing 'eyeGestures' is deprecated and will be removed in the future. " + "Please update your imports to use `eye_gestures`.", + DeprecationWarning, + stacklevel=2, +) + +import eye_gestures # noqa: E402 # pylint: disable=wrong-import-position + +# Alias `eyeGestures` to `eye_gestures` +sys.modules[__name__] = eye_gestures + +# Recursively alias `eyeGestures.*` to `eye_gestures.*` (incl. subsubmodules) +for _submodule in pkgutil.walk_packages( + eye_gestures.__path__, + prefix="eye_gestures.", + onerror=lambda _: None, # otherwise swallows ImportError throws +): try: - from EyegesturesEngine import ( - EyeGesturesEnginePython as RustEyeGesturesEngine, # type: ignore[import-not-found, import-untyped] - ) + _module = importlib.import_module(_submodule.name) except ImportError: - RustEyeGesturesEngine = None - -VERSION = "4.0.0" - - -class EyeGestures_v4: - """Main class for EyeGesture tracker. It configures and manages entire algorithm""" - - def __init__(self, calibration_radius: int = 1000) -> None: - self.calibration_radius = calibration_radius - - self.average_points: npt.NDArray[np.float64] = np.zeros((1, 2)) - self.calibration: bool = False - self.filled_points: int = 0 - - self.finder = FaceFinder() - self.face = Face() - self.engine: Optional[Any] = None - - self.fix: Optional[float] = None - self.fixationTracker: Dict[str, Fixation] = dict() - self.key_points_buffer: Dict[str, List[npt.NDArray[np.float64]]] = dict() - - self.starting_head_position = np.zeros((1, 2)) - self.starting_size = np.zeros((1, 2)) - - def getLandmarks(self, frame: cv2.typing.MatLike) -> Tuple[npt.NDArray[np.float64], bool, cv2.typing.MatLike]: - - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - frame = cv2.flip(frame, 1) - - # try: - self.face.process(frame, self.finder.find(frame)) - face_landmarks = self.face.get_landmarks() - assert face_landmarks is not None - l_eye = self.face.get_left_eye() - r_eye = self.face.get_right_eye() - l_eye_landmarks = l_eye.getLandmarks() - r_eye_landmarks = r_eye.getLandmarks() - blink = l_eye.getBlink() and r_eye.getBlink() - - # get x,y offset - x_offset = np.min(face_landmarks[:, 0]) - y_offset = np.min(face_landmarks[:, 1]) - x_width = np.max(face_landmarks[:, 0]) - x_offset - y_width = np.max(face_landmarks[:, 1]) - y_offset - - # get head position - head_offset = np.zeros((1, 2)) - scale_x = 1 - scale_y = 1 - if np.array_equal(self.starting_head_position, np.zeros((1, 2))): - self.starting_head_position = np.array([[x_offset, y_offset]]) - self.starting_size = np.array([[x_width, y_width]]) - else: - head_offset = np.array([[x_offset, y_offset]]) - self.starting_head_position - scale_x = self.starting_size[0, 0] / x_width - scale_y = self.starting_size[0, 1] / y_width - - # eye_events = np.array([event.blink,event.fixation]).reshape(1, 2) - assert l_eye_landmarks is not None - assert r_eye_landmarks is not None - key_points: npt.NDArray[np.float64] = np.concatenate( - ( - l_eye_landmarks, - r_eye_landmarks, - np.array([[scale_x, scale_y]]), - head_offset, - ) - ) - key_points[:, 0] = key_points[:, 0] - head_offset[:, 0] - key_points[:, 1] = key_points[:, 1] - head_offset[:, 1] - - key_points[:, 0] = key_points[:, 0] * scale_x - key_points[:, 1] = key_points[:, 1] * scale_y - - key_points[-1, 0] = head_offset[0, 0] - key_points[-1, 1] = head_offset[0, 1] - # print(self.starting_size,x_width,y_width) - subframe = frame[ - int(y_offset) : int(y_offset + y_width), - int(x_offset) : int(x_offset + x_width), - ] - return key_points, blink, subframe - - def whichAlgorithm(self) -> str: - return "rust engine" - - def reset(self) -> None: - self.filled_points = 0 - - def setFixation(self, fix: float) -> None: - self.fix = fix - - @recoverable(ret_error_params=(None, None, None)) - def step( - self, - frame: cv2.typing.MatLike, - width: int, - height: int, - ): - - if self.engine is None: - self.engine = RustEyeGesturesEngine(width, height) - - find_result = self.finder.find(frame) - if find_result is None or not find_result.face_landmarks: - raise ValueError("No face landmarks detected") - - landmarks = find_result.face_landmarks[0] - scaled_landmarks = np.array([(landmark.x, landmark.y) for landmark in landmarks], dtype=np.float64) - assert self.engine is not None - result = self.engine.process(scaled_landmarks.flatten().tolist()) - - x, y = result[0], result[1] - is_calibrating = result[2] == 1.0 - calib_x, calib_y = result[3], result[4] - calib_x = min(max(calib_x, 0), width) - calib_y = min(max(calib_y, 0), height) - return [x, y], is_calibrating, [calib_x, calib_y] + continue + _alias = __name__ + _submodule.name.removeprefix("eye_gestures") + sys.modules[_alias] = _module diff --git a/eyeGestures/EyegesturesEngine.cpython-313-x86_64-linux-gnu.so b/eye_gestures/EyegesturesEngine.cpython-313-x86_64-linux-gnu.so similarity index 100% rename from eyeGestures/EyegesturesEngine.cpython-313-x86_64-linux-gnu.so rename to eye_gestures/EyegesturesEngine.cpython-313-x86_64-linux-gnu.so diff --git a/eyeGestures/EyegesturesEngine.cpython-314-x86_64-linux-gnu.so b/eye_gestures/EyegesturesEngine.cpython-314-x86_64-linux-gnu.so similarity index 100% rename from eyeGestures/EyegesturesEngine.cpython-314-x86_64-linux-gnu.so rename to eye_gestures/EyegesturesEngine.cpython-314-x86_64-linux-gnu.so diff --git a/eyeGestures/Fixation.py b/eye_gestures/Fixation.py similarity index 100% rename from eyeGestures/Fixation.py rename to eye_gestures/Fixation.py diff --git a/eye_gestures/__init__.py b/eye_gestures/__init__.py new file mode 100644 index 0000000..fe00d63 --- /dev/null +++ b/eye_gestures/__init__.py @@ -0,0 +1,139 @@ +from typing import Any, Dict, List, Optional, Tuple + +import cv2 +import numpy as np +import numpy.typing as npt + +from eye_gestures.face import Face, FaceFinder +from eye_gestures.Fixation import Fixation +from eye_gestures.utils import recoverable + +try: + from .EyegesturesEngine import EyeGesturesEnginePython as RustEyeGesturesEngine # type: ignore[import-untyped] +except ImportError: + try: + from EyegesturesEngine import ( + EyeGesturesEnginePython as RustEyeGesturesEngine, # type: ignore[import-not-found, import-untyped] + ) + except ImportError: + RustEyeGesturesEngine = None + +VERSION = "4.0.0" + + +class EyeGestures_v4: + """Main class for EyeGesture tracker. It configures and manages entire algorithm""" + + def __init__(self, calibration_radius: int = 1000) -> None: + self.calibration_radius = calibration_radius + + self.average_points: npt.NDArray[np.float64] = np.zeros((1, 2)) + self.calibration: bool = False + self.filled_points: int = 0 + + self.finder = FaceFinder() + self.face = Face() + self.engine: Optional[Any] = None + + self.fix: Optional[float] = None + self.fixationTracker: Dict[str, Fixation] = dict() + self.key_points_buffer: Dict[str, List[npt.NDArray[np.float64]]] = dict() + + self.starting_head_position = np.zeros((1, 2)) + self.starting_size = np.zeros((1, 2)) + + def getLandmarks(self, frame: cv2.typing.MatLike) -> Tuple[npt.NDArray[np.float64], bool, cv2.typing.MatLike]: + + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + frame = cv2.flip(frame, 1) + + # try: + self.face.process(frame, self.finder.find(frame)) + face_landmarks = self.face.get_landmarks() + assert face_landmarks is not None + l_eye = self.face.get_left_eye() + r_eye = self.face.get_right_eye() + l_eye_landmarks = l_eye.getLandmarks() + r_eye_landmarks = r_eye.getLandmarks() + blink = l_eye.getBlink() and r_eye.getBlink() + + # get x,y offset + x_offset = np.min(face_landmarks[:, 0]) + y_offset = np.min(face_landmarks[:, 1]) + x_width = np.max(face_landmarks[:, 0]) - x_offset + y_width = np.max(face_landmarks[:, 1]) - y_offset + + # get head position + head_offset = np.zeros((1, 2)) + scale_x = 1 + scale_y = 1 + if np.array_equal(self.starting_head_position, np.zeros((1, 2))): + self.starting_head_position = np.array([[x_offset, y_offset]]) + self.starting_size = np.array([[x_width, y_width]]) + else: + head_offset = np.array([[x_offset, y_offset]]) - self.starting_head_position + scale_x = self.starting_size[0, 0] / x_width + scale_y = self.starting_size[0, 1] / y_width + + # eye_events = np.array([event.blink,event.fixation]).reshape(1, 2) + assert l_eye_landmarks is not None + assert r_eye_landmarks is not None + key_points: npt.NDArray[np.float64] = np.concatenate( + ( + l_eye_landmarks, + r_eye_landmarks, + np.array([[scale_x, scale_y]]), + head_offset, + ) + ) + key_points[:, 0] = key_points[:, 0] - head_offset[:, 0] + key_points[:, 1] = key_points[:, 1] - head_offset[:, 1] + + key_points[:, 0] = key_points[:, 0] * scale_x + key_points[:, 1] = key_points[:, 1] * scale_y + + key_points[-1, 0] = head_offset[0, 0] + key_points[-1, 1] = head_offset[0, 1] + # print(self.starting_size,x_width,y_width) + subframe = frame[ + int(y_offset) : int(y_offset + y_width), + int(x_offset) : int(x_offset + x_width), + ] + return key_points, blink, subframe + + def whichAlgorithm(self) -> str: + return "rust engine" + + def reset(self) -> None: + self.filled_points = 0 + + def setFixation(self, fix: float) -> None: + self.fix = fix + + @recoverable(ret_error_params=(None, None, None)) + def step( + self, + frame: cv2.typing.MatLike, + width: int, + height: int, + ): + + if self.engine is None: + self.engine = RustEyeGesturesEngine(width, height) + + find_result = self.finder.find(frame) + if find_result is None or not find_result.face_landmarks: + raise ValueError("No face landmarks detected") + + landmarks = find_result.face_landmarks[0] + scaled_landmarks = np.array([(landmark.x, landmark.y) for landmark in landmarks], dtype=np.float64) + assert self.engine is not None + result = self.engine.process(scaled_landmarks.flatten().tolist()) + + x, y = result[0], result[1] + is_calibrating = result[2] == 1.0 + calib_x, calib_y = result[3], result[4] + calib_x = min(max(calib_x, 0), width) + calib_y = min(max(calib_y, 0), height) + return [x, y], is_calibrating, [calib_x, calib_y] + diff --git a/eyeGestures/eye.py b/eye_gestures/eye.py similarity index 99% rename from eyeGestures/eye.py rename to eye_gestures/eye.py index 490f838..ec7c8c3 100644 --- a/eyeGestures/eye.py +++ b/eye_gestures/eye.py @@ -7,7 +7,7 @@ import numpy.typing as npt from mediapipe.tasks.python.vision.face_landmarker import FaceLandmarksConnections -from eyeGestures.utils import Buffor +from eye_gestures.utils import Buffor class Eye: diff --git a/eyeGestures/face.py b/eye_gestures/face.py similarity index 99% rename from eyeGestures/face.py rename to eye_gestures/face.py index ef481ec..9219bd6 100644 --- a/eyeGestures/face.py +++ b/eye_gestures/face.py @@ -12,7 +12,7 @@ from mediapipe.tasks import python from mediapipe.tasks.python import vision -from eyeGestures.eye import Eye +from eye_gestures.eye import Eye FACE_LANDMARKER_MODEL_URL = ( "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task" diff --git a/eyeGestures/gevent.py b/eye_gestures/gevent.py similarity index 97% rename from eyeGestures/gevent.py rename to eye_gestures/gevent.py index 7bc0181..393b6b2 100644 --- a/eyeGestures/gevent.py +++ b/eye_gestures/gevent.py @@ -6,7 +6,7 @@ import numpy as np import numpy.typing as npt -from eyeGestures.eye import Eye +from eye_gestures.eye import Eye class Gevent: diff --git a/eyeGestures/processing.py b/eye_gestures/processing.py similarity index 100% rename from eyeGestures/processing.py rename to eye_gestures/processing.py diff --git a/eyeGestures/requirements.txt b/eye_gestures/requirements.txt similarity index 100% rename from eyeGestures/requirements.txt rename to eye_gestures/requirements.txt diff --git a/eyeGestures/utils.py b/eye_gestures/utils.py similarity index 100% rename from eyeGestures/utils.py rename to eye_gestures/utils.py diff --git a/pyproject.toml b/pyproject.toml index 2aa061a..3ed2320 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,9 @@ manifest-path = "engine/Cargo.toml" features = ["python"] no-default-features = true python-source = "." -module-name = "eyeGestures.EyegesturesEngine" +module-name = "eye_gestures.EyegesturesEngine" +# Also include deprecated `eyeGestures` alias package alongside `eye_gestures` +include = ["eyeGestures/**/*.py"] [project.urls] Homepage = "https://github.com/NativeSensors/EyeGestures" @@ -87,5 +89,5 @@ module = ["sklearn.*", "mediapipe.*"] ignore_missing_imports = true [[tool.mypy.overrides]] -module = ["EyegesturesEngine", "eyeGestures.EyegesturesEngine"] +module = ["EyegesturesEngine", "eye_gestures.EyegesturesEngine"] ignore_missing_imports = true diff --git a/tests/test_package_rename.py b/tests/test_package_rename.py new file mode 100644 index 0000000..96c47bd --- /dev/null +++ b/tests/test_package_rename.py @@ -0,0 +1,52 @@ +import importlib +import sys +import unittest +import warnings + + +def _import_old_name_fresh(): + """Import `eyeGestures` from scratch, returning (module, caught warnings).""" + for name in [ + n for n in sys.modules + if n == "eyeGestures" or n.startswith("eyeGestures.") + ]: + del sys.modules[name] + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + module = importlib.import_module("eyeGestures") + return module, caught + + +class TestPackageRename(unittest.TestCase): + def test_old_name_emits_deprecation_warning(self): + _, caught = _import_old_name_fresh() + self.assertTrue( + any( + issubclass(w.category, DeprecationWarning) and + "eye_gestures" in str(w.message) + for w in caught + ) + ) + + def test_old_name_is_alias_of_new_package(self): + old, _ = _import_old_name_fresh() + new = importlib.import_module("eye_gestures") + self.assertIs(old, new) + + def test_submodules_are_shared_objects(self): + _import_old_name_fresh() + old_utils = importlib.import_module("eyeGestures.utils") + new_utils = importlib.import_module("eye_gestures.utils") + self.assertIs(old_utils, new_utils) + self.assertEqual(new_utils.__name__, "eye_gestures.utils") + + def test_from_import_of_public_api(self): + _import_old_name_fresh() + old = importlib.import_module("eyeGestures") + new = importlib.import_module("eye_gestures") + self.assertIs(old.EyeGestures_v4, new.EyeGestures_v4) + + +if __name__ == "__main__": + unittest.main() +