From b8a6d4e61547dcc612aa0657ef7842e069b6e568 Mon Sep 17 00:00:00 2001 From: Théo de la Hogue Date: Wed, 24 May 2023 11:59:20 +0200 Subject: Adding NearestNeighboIndex analysis. --- src/argaze/GazeAnalysis/NearestNeighborIndex.py | 49 +++++++++++++++++++++++++ src/argaze/GazeAnalysis/__init__.py | 2 +- src/argaze/utils/demo_gaze_features_run.py | 25 ++++++++++++- 3 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 src/argaze/GazeAnalysis/NearestNeighborIndex.py diff --git a/src/argaze/GazeAnalysis/NearestNeighborIndex.py b/src/argaze/GazeAnalysis/NearestNeighborIndex.py new file mode 100644 index 0000000..104bb30 --- /dev/null +++ b/src/argaze/GazeAnalysis/NearestNeighborIndex.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python + +""" """ + +__author__ = "Théo de la Hogue" +__credits__ = [] +__copyright__ = "Copyright 2023, Ecole Nationale de l'Aviation Civile (ENAC)" +__license__ = "BSD" + +from typing import TypeVar, Tuple, Any +from dataclasses import dataclass, field + +from argaze import GazeFeatures + +import numpy +from scipy.spatial.distance import cdist + +@dataclass +class ScanPathAnalyzer(GazeFeatures.ScanPathAnalyzer): + """Implementation of Nearest Neighbor Index (NNI) as described in Di Nocera et al., 2006 + """ + + def __post_init__(self): + + pass + + def analyze(self, scan_path: GazeFeatures.ScanPathType, screen_dimension: tuple[float, float]) -> float: + """Analyze scan path.""" + + assert(len(scan_path) > 1) + + # Gather fixations focus points + fixations_focus = [] + for step in scan_path: + fixations_focus.append(step.first_fixation.focus) + + # Compute inter fixation distances + distances = cdist(fixations_focus, fixations_focus) + + # Find minimal distances between each fixations + minimums = numpy.apply_along_axis(lambda row: numpy.min(row[numpy.nonzero(row)]), 1, distances) + + # Average of minimun distances + dNN = numpy.sum(minimums / len(fixations_focus)) + + # Mean random distance + dran = 0.5 * numpy.sqrt(screen_dimension[0] * screen_dimension[1] / len(fixations_focus)) + + return dNN / dran diff --git a/src/argaze/GazeAnalysis/__init__.py b/src/argaze/GazeAnalysis/__init__.py index cb18a08..f955856 100644 --- a/src/argaze/GazeAnalysis/__init__.py +++ b/src/argaze/GazeAnalysis/__init__.py @@ -2,4 +2,4 @@ .. include:: README.md """ __docformat__ = "restructuredtext" -__all__ = ['DispersionThresholdIdentification', 'VelocityThresholdIdentification', 'TransitionMatrix', 'CoefficientK', 'LempelZivComplexity', 'NGram', 'Entropy'] \ No newline at end of file +__all__ = ['DispersionThresholdIdentification', 'VelocityThresholdIdentification', 'TransitionMatrix', 'CoefficientK', 'LempelZivComplexity', 'NGram', 'Entropy', 'NearestNeighborIndex'] \ No newline at end of file diff --git a/src/argaze/utils/demo_gaze_features_run.py b/src/argaze/utils/demo_gaze_features_run.py index 50cf113..0e247e2 100644 --- a/src/argaze/utils/demo_gaze_features_run.py +++ b/src/argaze/utils/demo_gaze_features_run.py @@ -46,7 +46,7 @@ def main(): aoi_scene_filepath = os.path.join(current_directory, 'demo_environment/aoi_scene.jpg') aoi_scene_image = cv2.imread(aoi_scene_filepath) - window_size = [aoi_scene_image.shape[1], aoi_scene_image.shape[0]] + window_size = (aoi_scene_image.shape[1], aoi_scene_image.shape[0]) # Project AOI scene onto Full HD screen aoi_scene_projection = demo_scene.orthogonal_projection * window_size @@ -106,6 +106,10 @@ def main(): entropy_analysis = (-1, -1) enable_entropy_analysis = False + nni_analyzer = NearestNeighborIndex.ScanPathAnalyzer() + nni_analysis = 0 + enable_nni_analysis = False + gaze_movement_lock = threading.Lock() # Init timestamp @@ -126,6 +130,7 @@ def main(): nonlocal lzc_analysis nonlocal ngram_analysis nonlocal entropy_analysis + nonlocal nni_analysis # Edit millisecond timestamp data_ts = int((time.time() - start_ts) * 1e3) @@ -230,6 +235,10 @@ def main(): raw_cK_analysis = raw_cK_analyzer.analyze(raw_scan_path) + if enable_nni_analysis: + + nni_analysis = nni_analyzer.analyze(raw_scan_path, window_size) + # Append saccade to aoi scan path aoi_scan_path.append_saccade(data_ts, gaze_movement) @@ -294,6 +303,11 @@ def main(): display_hide = 'hide' if enable_entropy_analysis else 'display' cv2.putText(aoi_matrix, f'Entropy: {on_off} (Press \'e\' key to {display_hide})', (20, 280), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 255) if enable_entropy_analysis else (255, 255, 255), 1, cv2.LINE_AA) + # Write nni help + on_off = 'on' if enable_nni_analysis else 'off' + display_hide = 'hide' if enable_nni_analysis else 'display' + cv2.putText(aoi_matrix, f'Nearest neighbor index: {on_off} (Press \'i\' key to {display_hide})', (20, 320), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 255) if enable_nni_analysis else (255, 255, 255), 1, cv2.LINE_AA) + # Check fixation identification if gaze_movement_identifier[identification_mode].current_fixation != None: @@ -401,6 +415,11 @@ def main(): cv2.putText(aoi_matrix, f'Stationary entropy: {entropy_analysis[0]:.3f},', (20, window_size[1]-280), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 255), 1, cv2.LINE_AA) cv2.putText(aoi_matrix, f'Transition entropy: {entropy_analysis[1]:.3f},', (20, window_size[1]-240), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 255), 1, cv2.LINE_AA) + # Write NNI + if enable_nni_analysis: + + cv2.putText(aoi_matrix, f'Nearest neighbor index: {nni_analysis:.3f}', (20, window_size[1]-320), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 255), 1, cv2.LINE_AA) + # Unlock gaze movement identification gaze_movement_lock.release() @@ -462,6 +481,10 @@ def main(): enable_tm_analysis = True + # Enable NNI analysis with 'i' key + if key_pressed == 105: + + enable_nni_analysis = not enable_nni_analysis # Stop calibration by pressing 'Esc' key if cv2.waitKey(10) == 27: -- cgit v1.1