Source code for pyetsimul.geometry.conversions

"""Coordinate and gaze conversion utilities for eye tracking simulation.

Implements conversions between gaze direction, rotation angles, and observer/screen coordinates.
"""

import numpy as np

from ..types import Direction3D, Point2D, Point3D, Position3D, RotationMatrix, TransformationMatrix


[docs] def gaze2angle(gaze: Direction3D, rest_pos: RotationMatrix | None = None) -> Point2D: """Convert gaze direction to eye rotation angles. Calculates horizontal and vertical rotation angles from a 3D gaze direction relative to the eye's rest position. Uses Listing's law coordinate system. Args: gaze: 3D gaze direction vector rest_pos: Optional 3x3 rotation matrix for eye rest position. Defaults to [[-1,0,0], [0,0,1], [0,1,0]] Returns: Point2D containing [horizontal_angle, vertical_angle] in radians """ if not isinstance(gaze, Direction3D): raise TypeError(f"gaze must be Direction3D, got {type(gaze)}") # Default rest position if not provided if rest_pos is None: rest_pos = RotationMatrix(np.array([[-1, 0, 0], [0, 0, 1], [0, 1, 0]])) # Transform gaze to rest position coordinate system gaze_transformed = np.linalg.solve(rest_pos, np.array(gaze)[:3]) # Calculate rotation angles using arctan2 for proper quadrant handling horizontal_angle = np.arctan2(gaze_transformed[0], -gaze_transformed[2]) vertical_angle = np.arctan2(gaze_transformed[1], np.linalg.norm([gaze_transformed[0], gaze_transformed[2]])) return Point2D(horizontal_angle, vertical_angle)
[docs] def angle2gaze(angles: Point2D, rest_pos: RotationMatrix | None = None) -> Direction3D: """Convert eye rotation angles to gaze direction. Calculates gaze direction from horizontal and vertical rotation angles using Euler rotation matrices. Applies rotations in Listing's law order. Args: angles: 2D point containing rotation angles [horizontal, vertical] in radians rest_pos: Optional 3x3 rotation matrix for eye rest position. Defaults to [[-1,0,0], [0,0,1], [0,1,0]] Returns: Direction3D representing the gaze direction vector """ if not isinstance(angles, Point2D): raise TypeError(f"angles must be Point2D, got {type(angles)}") # Default rest position if not provided if rest_pos is None: rest_pos = RotationMatrix(np.array([[-1, 0, 0], [0, 0, 1], [0, 1, 0]])) # Create rotation matrices for horizontal and vertical rotations angles_arr = np.array(angles) cos_h, sin_h = np.cos(angles_arr[0]), np.sin(angles_arr[0]) cos_v, sin_v = np.cos(angles_arr[1]), np.sin(angles_arr[1]) # Horizontal rotation (x-z plane) rot_h = np.array([[cos_h, 0, -sin_h, 0], [0, 1, 0, 0], [sin_h, 0, cos_h, 0], [0, 0, 0, 1]]) # Vertical rotation (y-z plane) rot_v = np.array([[1, 0, 0, 0], [0, cos_v, -sin_v, 0], [0, sin_v, cos_v, 0], [0, 0, 0, 1]]) # Convert rest position to 4x4 homogeneous matrix rest_pos_4x4 = TransformationMatrix.from_rotation(rest_pos) # Apply transformations: rest_pos * rot_h * rot_v * default_gaze default_gaze = np.array([0, 0, -1, 0]) gaze_4d = rest_pos_4x4 @ rot_h @ rot_v @ default_gaze return Direction3D.from_array(gaze_4d[:3])
[docs] def calculate_angular_error_degrees( actual_point: Point3D, predicted_point: Point3D, observer_pos: Position3D ) -> float: """Calculate angular error between actual and predicted gaze points. Creates 3D gaze vectors from observer to each point and computes the angle between them using the dot product formula. Handles numerical precision issues. Args: actual_point: Actual target point [x, y, z] in mm predicted_point: Predicted gaze point [x, y, z] in mm observer_pos: Observer position [x, y, z] in mm Returns: Angular error in degrees """ if not isinstance(actual_point, Point3D): raise TypeError(f"actual_point must be Point3D, got {type(actual_point)}") if not isinstance(predicted_point, Point3D): raise TypeError(f"predicted_point must be Point3D, got {type(predicted_point)}") if not isinstance(observer_pos, Position3D): raise TypeError(f"observer_pos must be Position3D, got {type(observer_pos)}") # Create 3D gaze vectors from observer to target points using structured types gaze_actual = actual_point - observer_pos gaze_predicted = predicted_point - observer_pos # Calculate vector magnitudes actual_magnitude = np.sqrt(gaze_actual.x**2 + gaze_actual.y**2 + gaze_actual.z**2) predicted_magnitude = np.sqrt(gaze_predicted.x**2 + gaze_predicted.y**2 + gaze_predicted.z**2) # Calculate dot product manually using structured type components dot_product = ( gaze_actual.x * gaze_predicted.x + gaze_actual.y * gaze_predicted.y + gaze_actual.z * gaze_predicted.z ) # Normalize dot product normalized_dot = dot_product / (actual_magnitude * predicted_magnitude) # Clip for numerical stability and calculate angle normalized_dot = np.clip(normalized_dot, -1, 1) return float(np.degrees(np.arccos(normalized_dot)))