""" This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . """ __author__ = "Théo de la Hogue" __credits__ = [] __copyright__ = "Copyright 2023, Ecole Nationale de l'Aviation Civile (ENAC)" __license__ = "GPLv3" import unittest import random import time import math from argaze import GazeFeatures from argaze.GazeAnalysis import DispersionThresholdIdentification import numpy def build_gaze_fixation(size: int, center: tuple, deviation_max: float, min_time: float, max_time: float, start_ts: float = 0., validity: list = []): """ Generate N TimeStampedGazePsoitions dispersed around a center point 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): # Sleep a random time sleep_time = random.random() * (max_time - min_time) + min_time time.sleep(sleep_time) # Check position validity valid = True if len(validity) > i: valid = validity[i] if valid: # Edit gaze position random_x = center[0] + deviation_max * (random.random() - 0.5) / math.sqrt(2) random_y = center[1] + deviation_max * (random.random() - 0.5) / math.sqrt(2) gaze_position = GazeFeatures.GazePosition((random_x, random_y)) else: gaze_position = GazeFeatures.GazePosition() # Timestamp gaze position gaze_position.timestamp = time.time() - start_time + start_ts # Store gaze position ts_gaze_positions.append(gaze_position) 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): # Sleep a random time sleep_time = random.random() * (max_time - min_time) + min_time time.sleep(sleep_time) # 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.GazePosition() # Timestamp gaze position gaze_position.timestamp = time.time() - start_time + start_ts # Store gaze position ts_gaze_positions.append(gaze_position) return ts_gaze_positions class TestDispersionThresholdIdentificationClass(unittest.TestCase): """Test DispersionThresholdIdentification class.""" def test_fixation_identification(self): """Test DispersionThresholdIdentification fixation identification.""" size = 10 center = (0, 0) deviation_max = 10 min_time = 0.01 max_time = 0.1 ts_gaze_positions = build_gaze_fixation(size, center, deviation_max, min_time, max_time) gaze_movement_identifier = DispersionThresholdIdentification.GazeMovementIdentifier(deviation_max_threshold=deviation_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) # Check fixation fixation = ts_fixations.pop(0) self.assertEqual(len(fixation), size) self.assertLessEqual(fixation.deviation_max, deviation_max) self.assertGreaterEqual(fixation.duration, (size - 1) * min_time) self.assertLessEqual(fixation.duration, (size - 1) * max_time) self.assertLessEqual(fixation.is_finished(), True) def test_fixation_and_direct_saccade_identification(self): """Test DispersionThresholdIdentification fixation and saccade identification.""" size = 10 center_A = (0, 0) center_B = (50, 50) deviation_max = 10 min_time = 0.01 max_time = 0.1 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[-1].timestamp) ts_gaze_positions = ts_gaze_positions_A + ts_gaze_positions_B gaze_movement_identifier = DispersionThresholdIdentification.GazeMovementIdentifier(deviation_max_threshold=deviation_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) # Check first fixation fixation = ts_fixations.pop(0) self.assertEqual(len(fixation), size) self.assertLessEqual(fixation.deviation_max, deviation_max) self.assertGreaterEqual(fixation.duration, (size - 1) * min_time) self.assertLessEqual(fixation.duration, (size - 1) * max_time) self.assertLessEqual(fixation.is_finished(), True) # Check first saccade saccade = ts_saccades.pop(0) self.assertEqual(len(saccade), 2) self.assertGreaterEqual(saccade.duration, min_time) self.assertLessEqual(saccade.duration, max_time) self.assertLessEqual(saccade.is_finished(), True) # Check that last position of a movement is equal to first position of next movement self.assertEqual(fixation[-1].timestamp, saccade[0].timestamp) self.assertEqual(fixation[-1].value, saccade[0].value) # Check second fixation fixation = ts_fixations.pop(0) self.assertEqual(len(fixation), size) self.assertLessEqual(fixation.deviation_max, deviation_max) self.assertGreaterEqual(fixation.duration, (size - 1) * min_time) self.assertLessEqual(fixation.duration, (size - 1) * max_time) self.assertLessEqual(fixation.is_finished(), True) # Check that last position of a movement is equal to first position of next movement self.assertEqual(saccade[-1].timestamp, fixation[0].timestamp) self.assertEqual(saccade[-1].value, fixation[0].value) def test_fixation_and_short_saccade_identification(self): """Test DispersionThresholdIdentification fixation and saccade identification.""" size = 10 move = 2 center_A = (0, 0) out_A = (20, 20) center_B = (50, 50) deviation_max = 10 min_time = 0.01 max_time = 0.1 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[-1].timestamp) ts_gaze_positions_B = build_gaze_fixation(size, center_B, deviation_max, min_time, max_time, start_ts=ts_move_positions[-1].timestamp) ts_gaze_positions = ts_gaze_positions_A + ts_move_positions + ts_gaze_positions_B gaze_movement_identifier = DispersionThresholdIdentification.GazeMovementIdentifier(deviation_max_threshold=deviation_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 + move) # Check first fixation fixation = ts_fixations.pop(0) self.assertEqual(len(fixation), size) self.assertLessEqual(fixation.deviation_max, deviation_max) self.assertGreaterEqual(fixation.duration, (size - 1) * min_time) self.assertLessEqual(fixation.duration, (size - 1) * max_time) self.assertLessEqual(fixation.is_finished(), True) # Check first saccade saccade = ts_saccades.pop(0) self.assertEqual(len(saccade), move + 2) self.assertGreaterEqual(saccade.duration, (move + 1) * min_time) self.assertLessEqual(saccade.duration, (move + 1) * max_time) self.assertLessEqual(saccade.is_finished(), True) # Check that last position of a movement is equal to first position of next movement self.assertEqual(fixation[-1].timestamp, saccade[0].timestamp) self.assertEqual(fixation[-1].value, saccade[0].value) # Check second fixation fixation = ts_fixations.pop(0) self.assertEqual(len(fixation), size) self.assertLessEqual(fixation.deviation_max, deviation_max) self.assertGreaterEqual(fixation.duration, (size - 1) * min_time) self.assertLessEqual(fixation.duration, (size - 1) * max_time) self.assertLessEqual(fixation.is_finished(), True) # Check that last position of a movement is equal to first position of next movement self.assertEqual(saccade[-1].timestamp, fixation[0].timestamp) self.assertEqual(saccade[-1].value, fixation[0].value) def test_empty_gaze_position(self): """Test DispersionThresholdIdentification fixation and saccade identification with empty gaze position.""" size = 15 center = (0, 0) deviation_max = 10 min_time = 0.05 max_time = 0.1 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 = DispersionThresholdIdentification.GazeMovementIdentifier(deviation_max_threshold=deviation_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), size-3) # Check first fixation fixation = ts_fixations.pop(0) self.assertEqual(len(fixation), 7) self.assertLessEqual(fixation.deviation_max, deviation_max) self.assertGreaterEqual(fixation.duration, 6 * min_time) self.assertLessEqual(fixation.duration, 6 * max_time) self.assertLessEqual(fixation.is_finished(), True) # Check second fixation fixation = ts_fixations.pop(0) self.assertEqual(len(fixation), 5) self.assertLessEqual(fixation.deviation_max, deviation_max) self.assertGreaterEqual(fixation.duration, 4 * min_time) self.assertLessEqual(fixation.duration, 4 * max_time) self.assertLessEqual(fixation.is_finished(), True) def test_fixation_overlapping(self): """Test Fixation overlap function.""" size = 10 center_A = (0, 0) center_B = (50, 50) center_C = (55, 55) deviation_max = 15 min_time = 0.01 max_time = 0.1 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[-1].timestamp) ts_gaze_positions_C = build_gaze_fixation(size, center_C, deviation_max, min_time, max_time, start_ts=ts_gaze_positions_B[-1].timestamp) fixation_A = DispersionThresholdIdentification.Fixation(ts_gaze_positions_A) fixation_B = DispersionThresholdIdentification.Fixation(ts_gaze_positions_B) fixation_C = DispersionThresholdIdentification.Fixation(ts_gaze_positions_C) # Check that fixation doesn't overlap self.assertFalse(fixation_A.is_overlapping()(fixation_B)) self.assertFalse(fixation_B.is_overlapping()(fixation_A)) # Check that fixation overlaps self.assertTrue(fixation_B.is_overlapping()(fixation_C)) self.assertTrue(fixation_C.is_overlapping()(fixation_B)) @unittest.skip("Fixation overlapping is not supported anymore.") def test_fixation_overlapping_identification(self): """Test DispersionThresholdIdentification identification when fixations overlap.""" size = 50 center_A = (-5, 0) center_B = (5, 0) deviation_max = 15 min_time = 0.01 max_time = 0.1 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[-1].timestamp) ts_gaze_positions = ts_gaze_positions_A.append(ts_gaze_positions_B) gaze_movement_identifier = DispersionThresholdIdentification.GazeMovementIdentifier(deviation_max_threshold=deviation_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*2) # Check unique fixation fixation = ts_fixations.pop(0) self.assertEqual(len(fixation), size * 2) #self.assertGreaterEqual(fixation.deviation_max, deviation_max) self.assertGreaterEqual(fixation.duration, (2 * size - 1) * min_time) self.assertLessEqual(fixation.duration, (2 * size - 1) * max_time) self.assertLessEqual(fixation.is_finished(), True) def test_identification_browsing(self): """Test DispersionThresholdIdentification identification browsing.""" size = 10 center_A = (0, 0) center_B = (50, 50) deviation_max = 10 min_time = 0.01 max_time = 0.1 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[-1].timestamp) ts_gaze_positions = ts_gaze_positions_A + ts_gaze_positions_B gaze_movement_identifier = DispersionThresholdIdentification.GazeMovementIdentifier(deviation_max_threshold=deviation_max, duration_min_threshold=max_time*2) # Iterate on gaze positions for gaze_position in ts_gaze_positions: finished_gaze_movement = gaze_movement_identifier.identify(gaze_position, terminate=(gaze_position.timestamp == ts_gaze_positions[-1].timestamp)) if GazeFeatures.is_fixation(finished_gaze_movement): self.assertEqual(len(finished_gaze_movement), size) self.assertLessEqual(finished_gaze_movement.deviation_max, deviation_max) self.assertGreaterEqual(finished_gaze_movement.duration, (size-1) * min_time) self.assertLessEqual(finished_gaze_movement.duration, (size-1) * max_time) self.assertLessEqual(finished_gaze_movement.is_finished(), True) elif GazeFeatures.is_saccade(finished_gaze_movement): self.assertEqual(len(finished_gaze_movement), 2) self.assertGreaterEqual(finished_gaze_movement.duration, min_time) self.assertLessEqual(finished_gaze_movement.duration, max_time) self.assertLessEqual(finished_gaze_movement.is_finished(), True) # Check that last gaze position date of current fixation is equal to given gaze position date # NOTE: This is not true for saccade as, for I-DT, there is a minimal time window while the gaze movement is unknown current_gaze_movement = gaze_movement_identifier.current_gaze_movement() if current_gaze_movement: if GazeFeatures.is_fixation(current_gaze_movement): self.assertEqual(current_gaze_movement[-1].timestamp, gaze_position.timestamp) def test_identification_generator(self): """Test DispersionThresholdIdentification identification using generator.""" size = 10 center_A = (0, 0) center_B = (50, 50) deviation_max = 10 min_time = 0.01 max_time = 0.1 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[-1].timestamp) ts_gaze_positions = ts_gaze_positions_A + ts_gaze_positions_B gaze_movement_identifier = DispersionThresholdIdentification.GazeMovementIdentifier(deviation_max_threshold=deviation_max, duration_min_threshold=max_time*2) for finished_gaze_movement in gaze_movement_identifier(ts_gaze_positions): if GazeFeatures.is_fixation(finished_gaze_movement): self.assertEqual(len(finished_gaze_movement), size) self.assertLessEqual(finished_gaze_movement.deviation_max, deviation_max) self.assertGreaterEqual(finished_gaze_movement.duration, size * min_time) self.assertLessEqual(finished_gaze_movement.duration, size * max_time) self.assertLessEqual(finished_gaze_movement.is_finished(), True) elif GazeFeatures.is_saccade(finished_gaze_movement): self.assertEqual(len(finished_gaze_movement), 2) self.assertGreaterEqual(finished_gaze_movement.duration, 2 * min_time) self.assertLessEqual(finished_gaze_movement.duration, 2 * max_time) self.assertLessEqual(finished_gaze_movement.is_finished(), True) if __name__ == '__main__': unittest.main()