From c8b6647d4d8390655e8f57baef3e6557e5fd605a Mon Sep 17 00:00:00 2001 From: Théo de la Hogue Date: Wed, 10 May 2023 15:10:40 +0200 Subject: Adding new identification algorithm. --- .../VelocityThresholdIdentification.py | 180 +++++++++++++++++++++ src/argaze/GazeAnalysis/__init__.py | 2 +- 2 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 src/argaze/GazeAnalysis/VelocityThresholdIdentification.py diff --git a/src/argaze/GazeAnalysis/VelocityThresholdIdentification.py b/src/argaze/GazeAnalysis/VelocityThresholdIdentification.py new file mode 100644 index 0000000..5e6ab26 --- /dev/null +++ b/src/argaze/GazeAnalysis/VelocityThresholdIdentification.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python + +from typing import TypeVar, Tuple +from dataclasses import dataclass, field +import math + +from argaze import GazeFeatures + +import numpy + +FixationType = TypeVar('Fixation', bound="Fixation") +# Type definition for type annotation convenience + +SaccadeType = TypeVar('Saccade', bound="Saccade") +# Type definition for type annotation convenience + +@dataclass(frozen=True) +class Fixation(GazeFeatures.Fixation): + """Define dispersion based fixation.""" + + deviation_max: float = field(init=False) + """Maximal gaze position distance to the centroïd.""" + + def __post_init__(self): + + super().__post_init__() + + points = self.positions.values() + points_x, points_y = [p[0] for p in points], [p[1] for p in points] + points_array = numpy.column_stack([points_x, points_y]) + centroid_array = numpy.array([numpy.mean(points_x), numpy.mean(points_y)]) + deviations_array = numpy.sqrt(numpy.sum((points_array - centroid_array)**2, axis=1)) + + # Update frozen focus attribute using centroid + object.__setattr__(self, 'focus', (centroid_array[0], centroid_array[1])) + + # Update frozen deviation_max attribute + object.__setattr__(self, 'deviation_max', max(deviations_array)) + + def point_deviation(self, gaze_position) -> float: + """Get distance of a point from the fixation's centroïd.""" + + return numpy.sqrt((self.centroid[0] - gaze_position.value[0])**2 + (self.centroid[1] - gaze_position.value[1])**2) + + def overlap(self, fixation) -> bool: + """Does a gaze position from another fixation having a deviation to this fixation centroïd smaller than maximal deviation?""" + + points = fixation.positions.values() + points_x, points_y = [p[0] for p in points], [p[1] for p in points] + points_array = numpy.column_stack([points_x, points_y]) + centroid_array = numpy.array([self.centroid[0], self.centroid[1]]) + deviations_array = numpy.sqrt(numpy.sum((points_array - centroid_array)**2, axis=1)) + + return min(deviations_array) <= self.deviation_max + + def merge(self, fixation) -> FixationType: + """Merge another fixation into this fixation.""" + + self.positions.append(fixation.positions) + self.__post_init__() + + return self + +@dataclass(frozen=True) +class Saccade(GazeFeatures.Saccade): + """Define dispersion based saccade.""" + + def __post_init__(self): + super().__post_init__() + +@dataclass +class GazeMovementIdentifier(GazeFeatures.GazeMovementIdentifier): + """Implementation of the I-VT algorithm as described in: + + Dario D. Salvucci and Joseph H. Goldberg. 2000. Identifying fixations and + saccades in eye-tracking protocols. In Proceedings of the 2000 symposium + on Eye tracking research & applications (ETRA '00). ACM, New York, NY, USA, + 71-78. [DOI=http://dx.doi.org/10.1145/355017.355028](DOI=http://dx.doi.org/10.1145/355017.355028) + """ + + velocity_max_threshold: int|float + """Maximal velocity allowed to consider a gaze movement as a fixation.""" + + duration_min_threshold: int|float + """Minimal duration allowed to consider a gaze movement as a fixation. + It is also used as maximal duration allowed to consider a gaze movement as a saccade.""" + + def __post_init__(self): + + self.__last_ts = -1 + self.__last_position = None + + self.__fixation_positions = GazeFeatures.TimeStampedGazePositions() + self.__saccade_positions = GazeFeatures.TimeStampedGazePositions() + + def identify(self, ts, gaze_position, terminate=False): + """Identify gaze movement from successive timestamped gaze positions. + + The optional *terminate* argument allows to notify identification algorithm that given gaze position will be the last one. + """ + + # Ignore non valid gaze position + if not gaze_position.valid: + + return None if not terminate else self.current_fixation + + # Store first valid position + if self.__last_ts < 0: + + self.__last_ts = ts + self.__last_position = gaze_position + + return + + # Check if too much time elapsed since last gaze position + if (ts - self.__last_ts) > self.duration_min_threshold: + + # Clear all former gaze positions + self.__last_ts = ts + self.__last_position = gaze_position + + self.__fixation_positions = GazeFeatures.TimeStampedGazePositions() + self.__saccade_positions = GazeFeatures.TimeStampedGazePositions() + + return + + # Velocity + velocity = gaze_position.distance(self.__last_position) / (ts - self.__last_ts) + + # Remember last position + self.__last_ts = ts + self.__last_position = gaze_position + + # Velocity is greater than threshold + if velocity > self.velocity_max_threshold: + + # Append to saccade positions + self.__saccade_positions[ts] = gaze_position + + # Does last fixation exist? + if len(self.__fixation_positions) > 0: + + last_fixation = Fixation(self.__fixation_positions) + + # Clear fixation positions + self.__fixation_positions = GazeFeatures.TimeStampedGazePositions() + + # Output last fixation + return last_fixation if not terminate else self.current_saccade + + # Velocity is less or equals to threshold + else: + + # Append to fixation positions + self.__fixation_positions[ts] = gaze_position + + # Does last saccade exist? + if len(self.__saccade_positions) > 0: + + last_saccade = Saccade(self.__saccade_positions) + + # Clear fixation positions + self.__saccade_positions = GazeFeatures.TimeStampedGazePositions() + + # Output last saccade + return last_saccade if not terminate else self.current_fixation + + @property + def current_fixation(self) -> FixationType: + + if len(self.__fixation_positions) > 0: + + return Fixation(self.__fixation_positions) + + @property + def current_saccade(self) -> SaccadeType: + + if len(self.__saccade_positions) > 0: + + return Saccade(self.__saccade_positions) diff --git a/src/argaze/GazeAnalysis/__init__.py b/src/argaze/GazeAnalysis/__init__.py index 287425f..7603693 100644 --- a/src/argaze/GazeAnalysis/__init__.py +++ b/src/argaze/GazeAnalysis/__init__.py @@ -2,4 +2,4 @@ .. include:: README.md """ __docformat__ = "restructuredtext" -__all__ = ['DispersionThresholdIdentification', 'TransitionProbabilityMatrix'] \ No newline at end of file +__all__ = ['DispersionThresholdIdentification', 'VelocityThresholdIdentification', 'TransitionProbabilityMatrix'] \ No newline at end of file -- cgit v1.1