From 162617bc836e301f8ed1cd657bae11a1d5304bff Mon Sep 17 00:00:00 2001 From: Théo de la Hogue Date: Wed, 31 May 2023 11:33:50 +0200 Subject: testing and fixing VelocityThresholdIdentification. --- .../VelocityThresholdIdentification.py | 261 +++++++++++++++++++++ .../VelocityThresholdIdentification.py | 23 +- 2 files changed, 279 insertions(+), 5 deletions(-) create mode 100644 src/argaze.test/GazeAnalysis/VelocityThresholdIdentification.py (limited to 'src') diff --git a/src/argaze.test/GazeAnalysis/VelocityThresholdIdentification.py b/src/argaze.test/GazeAnalysis/VelocityThresholdIdentification.py new file mode 100644 index 0000000..4cf3a81 --- /dev/null +++ b/src/argaze.test/GazeAnalysis/VelocityThresholdIdentification.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python + +""" """ + +__author__ = "Théo de la Hogue" +__credits__ = [] +__copyright__ = "Copyright 2023, Ecole Nationale de l'Aviation Civile (ENAC)" +__license__ = "BSD" + +import unittest +import random +import time +import math + +from argaze import GazeFeatures +from argaze.GazeAnalysis import VelocityThresholdIdentification + +import numpy + +def build_gaze_fixation(size: int, start_position: tuple, deviation_max: float, min_time: float, max_time: float, start_ts: float = 0., validity: list = []): + """ Generate N TimeStampedGazePositions strating from a starting position for testing purpose. + Timestamps are current time after random sleep (second). + GazePositions are random values. + """ + ts_gaze_positions = GazeFeatures.TimeStampedGazePositions() + + start_time = time.time() + + last_valid_position = start_position + + for i in range(0, size): + + # Check position validity + valid = True + if len(validity) > i: + + valid = validity[i] + + if valid: + + # Edit gaze position + random_x = last_valid_position[0] + deviation_max * (random.random() - 0.5) + random_y = last_valid_position[1] + deviation_max * (random.random() - 0.5) + + gaze_position = GazeFeatures.GazePosition((random_x, random_y)) + + # Remember last valid gaze position + last_valid_position = gaze_position.value + + else: + + gaze_position = GazeFeatures.UnvalidGazePosition() + + # Store gaze position + ts = time.time() - start_time + start_ts + ts_gaze_positions[ts] = gaze_position + + # Sleep a random time + sleep_time = random.random() * (max_time - min_time) + min_time + time.sleep(sleep_time) + + return ts_gaze_positions + +def build_gaze_saccade(size: int, center_A: tuple, center_B: tuple, min_time: float, max_time: float, start_ts: float = 0., validity: list = []): + """ Generate N TimeStampedGazePsoitions between 2 center points for testing purpose. + Timestamps are current time after random sleep (second). + GazePositions are random values. + """ + ts_gaze_positions = GazeFeatures.TimeStampedGazePositions() + + start_time = time.time() + + for i in range(0, size): + + # Check position validity + valid = True + if len(validity) > i: + + valid = validity[i] + + if valid: + + # Edit gaze position + move_x = center_A[0] + (center_B[0] - center_A[0]) * (i / size) + move_y = center_A[1] + (center_B[1] - center_A[1]) * (i / size) + gaze_position = GazeFeatures.GazePosition((move_x, move_y)) + + else: + + gaze_position = GazeFeatures.UnvalidGazePosition() + + # Store gaze position + ts = time.time() - start_time + start_ts + ts_gaze_positions[ts] = gaze_position + + # Sleep a random time + sleep_time = random.random() * (max_time - min_time) + min_time + time.sleep(sleep_time) + + return ts_gaze_positions + +class TestVelocityThresholdIdentificationClass(unittest.TestCase): + """Test VelocityThresholdIdentification class.""" + + def test_fixation_identification(self): + """Test VelocityThresholdIdentification fixation identification.""" + + size = 10 + center = (0, 0) + deviation_max = 10 + min_time = 0.05 + max_time = 0.1 + velocity_max = math.sqrt(2) * deviation_max / min_time + + ts_gaze_positions = build_gaze_fixation(size, center, deviation_max, min_time, max_time) + gaze_movement_identifier = VelocityThresholdIdentification.GazeMovementIdentifier(velocity_max_threshold=velocity_max, duration_min_threshold=max_time*2) + ts_fixations, ts_saccades, ts_status = gaze_movement_identifier.browse(ts_gaze_positions) + + # Check result size + self.assertEqual(len(ts_fixations), 1) + self.assertEqual(len(ts_saccades), 0) + self.assertEqual(len(ts_status), size-1) + + # Check fixation + ts, fixation = ts_fixations.pop_first() + + self.assertEqual(len(fixation.positions.keys()), size-1) + self.assertGreaterEqual(fixation.duration, size * min_time) + self.assertLessEqual(fixation.duration, size * max_time) + + def test_fixation_and_direct_saccade_identification(self): + """Test VelocityThresholdIdentification fixation and saccade identification.""" + + size = 10 + center_A = (0, 0) + center_B = (500, 500) + deviation_max = 10 + min_time = 0.05 + max_time = 0.1 + velocity_max = math.sqrt(2) * deviation_max / min_time + + ts_gaze_positions_A = build_gaze_fixation(size, center_A, deviation_max, min_time, max_time) + ts_gaze_positions_B = build_gaze_fixation(size, center_B, deviation_max, min_time, max_time, start_ts=ts_gaze_positions_A.last[0]) + + ts_gaze_positions = ts_gaze_positions_A.append(ts_gaze_positions_B) + + gaze_movement_identifier = VelocityThresholdIdentification.GazeMovementIdentifier(velocity_max_threshold=velocity_max, duration_min_threshold=max_time*2) + ts_fixations, ts_saccades, ts_status = gaze_movement_identifier.browse(ts_gaze_positions) + + # Check result size + self.assertEqual(len(ts_fixations), 2) + self.assertEqual(len(ts_saccades), 1) + self.assertEqual(len(ts_status), size*2 - 1) + + # Check first fixation + ts, fixation = ts_fixations.pop_first() + + self.assertEqual(len(fixation.positions.keys()), size-1) + self.assertGreaterEqual(fixation.duration, size * min_time) + self.assertLessEqual(fixation.duration, size * max_time) + + # Check first saccade + ts, saccade = ts_saccades.pop_first() + + self.assertEqual(len(saccade.positions.keys()), 1) + self.assertGreaterEqual(saccade.duration, 0.) + self.assertLessEqual(saccade.duration, 0.) + + # Check second fixation + ts, fixation = ts_fixations.pop_first() + + self.assertEqual(len(fixation.positions.keys()), size-1) + self.assertGreaterEqual(fixation.duration, size * min_time) + self.assertLessEqual(fixation.duration, size * max_time) + + def test_fixation_and_short_saccade_identification(self): + """Test VelocityThresholdIdentification fixation and saccade identification.""" + + size = 10 + move = 2 + center_A = (0, 0) + out_A = (10, 10) + center_B = (50, 50) + deviation_max = 10 + min_time = 0.05 + max_time = 0.1 + velocity_max = math.sqrt(2) * deviation_max / min_time + + ts_gaze_positions_A = build_gaze_fixation(size, center_A, deviation_max, min_time, max_time) + ts_move_positions = build_gaze_saccade(move, out_A, center_B, min_time, min_time, start_ts=ts_gaze_positions_A.last[0]) + ts_gaze_positions_B = build_gaze_fixation(size, center_B, deviation_max, min_time, max_time, start_ts=ts_move_positions.last[0]) + + ts_gaze_positions = ts_gaze_positions_A.append(ts_move_positions).append(ts_gaze_positions_B) + + gaze_movement_identifier = VelocityThresholdIdentification.GazeMovementIdentifier(velocity_max_threshold=velocity_max, duration_min_threshold=max_time*2) + ts_fixations, ts_saccades, ts_status = gaze_movement_identifier.browse(ts_gaze_positions) + + # Check result size + self.assertEqual(len(ts_fixations), 2) + self.assertEqual(len(ts_saccades), 1) + self.assertEqual(len(ts_status), 2 * size - 1 + move) + + # Check first fixation + ts, fixation = ts_fixations.pop_first() + + self.assertEqual(len(fixation.positions.keys()), size-1) + self.assertGreaterEqual(fixation.duration, size * min_time) + self.assertLessEqual(fixation.duration, size * max_time) + + # Check first saccade + ts, saccade = ts_saccades.pop_first() + + self.assertEqual(len(saccade.positions.keys()), move+1) + self.assertGreaterEqual(saccade.duration, min_time) + self.assertLessEqual(saccade.duration, max_time) + + # Check second fixation + ts, fixation = ts_fixations.pop_first() + + self.assertEqual(len(fixation.positions.keys()), size-1) + self.assertGreaterEqual(fixation.duration, size * min_time) + self.assertLessEqual(fixation.duration, size * max_time) + + def test_invalid_gaze_position(self): + """Test VelocityThresholdIdentification fixation and saccade identification with invalid gaze position.""" + + size = 15 + center = (0, 0) + deviation_max = 10 + min_time = 0.05 + max_time = 0.1 + velocity_max = math.sqrt(2) * deviation_max / min_time + validity = [True, True, True, True, True, True, True, False, False, False, True, True, True, True, True] + + ts_gaze_positions = build_gaze_fixation(size, center, deviation_max, min_time, max_time, validity=validity) + + gaze_movement_identifier = VelocityThresholdIdentification.GazeMovementIdentifier(velocity_max_threshold=velocity_max, duration_min_threshold=max_time*2) + ts_fixations, ts_saccades, ts_status = gaze_movement_identifier.browse(ts_gaze_positions) + + # Check result size + self.assertEqual(len(ts_fixations), 2) + self.assertEqual(len(ts_saccades), 0) + self.assertEqual(len(ts_status), len(validity)-5) + + # Check first fixation + ts, fixation = ts_fixations.pop_first() + + self.assertEqual(len(fixation.positions.keys()), 6) + self.assertGreaterEqual(fixation.duration, 6 * min_time) + self.assertLessEqual(fixation.duration, 6 * max_time) + + # Check second fixation + ts, fixation = ts_fixations.pop_first() + + self.assertEqual(len(fixation.positions.keys()), 4) + self.assertGreaterEqual(fixation.duration, 4 * min_time) + self.assertLessEqual(fixation.duration, 4 * max_time) + +if __name__ == '__main__': + + unittest.main() \ No newline at end of file diff --git a/src/argaze/GazeAnalysis/VelocityThresholdIdentification.py b/src/argaze/GazeAnalysis/VelocityThresholdIdentification.py index 3c3d58f..1cf7859 100644 --- a/src/argaze/GazeAnalysis/VelocityThresholdIdentification.py +++ b/src/argaze/GazeAnalysis/VelocityThresholdIdentification.py @@ -141,13 +141,16 @@ class GazeMovementIdentifier(GazeFeatures.GazeMovementIdentifier): self.__last_ts = ts self.__last_position = gaze_position + # Get last movement + last_movement = self.current_saccade if len(self.__fixation_positions) == 0 else self.current_fixation + self.__fixation_positions = GazeFeatures.TimeStampedGazePositions() self.__saccade_positions = GazeFeatures.TimeStampedGazePositions() - return + return last_movement # Velocity - velocity = gaze_position.distance(self.__last_position) / (ts - self.__last_ts) + velocity = abs(gaze_position.distance(self.__last_position) / (ts - self.__last_ts)) # Remember last position self.__last_ts = ts @@ -168,7 +171,12 @@ class GazeMovementIdentifier(GazeFeatures.GazeMovementIdentifier): self.__fixation_positions = GazeFeatures.TimeStampedGazePositions() # Output last fixation - return last_fixation if not terminate else self.current_saccade + return last_fixation + + # Identification must stop: ends with current saccade + if terminate: + + return self.current_saccade # Velocity is less or equals to threshold else: @@ -185,7 +193,12 @@ class GazeMovementIdentifier(GazeFeatures.GazeMovementIdentifier): self.__saccade_positions = GazeFeatures.TimeStampedGazePositions() # Output last saccade - return last_saccade if not terminate else self.current_fixation + return last_saccade + + # Identification must stop: ends with current fixation + if terminate: + + return self.current_fixation @property def current_fixation(self) -> FixationType: @@ -198,5 +211,5 @@ class GazeMovementIdentifier(GazeFeatures.GazeMovementIdentifier): def current_saccade(self) -> SaccadeType: if len(self.__saccade_positions) > 0: - + return Saccade(self.__saccade_positions) -- cgit v1.1