From 431840242b54801a683b9f3491dcc37f1ce499c2 Mon Sep 17 00:00:00 2001 From: Théo de la Hogue Date: Fri, 25 Mar 2022 02:45:15 +0100 Subject: Improving data managment --- src/argaze/DataAnalysis/DictObject.py | 20 +++ src/argaze/DataAnalysis/FixationsAndSaccades.py | 166 --------------------- src/argaze/DataAnalysis/GazeAnalysis.py | 151 +++++++++++++++++++ src/argaze/DataAnalysis/GenericData.py | 19 --- src/argaze/DataAnalysis/TimeStampedData.py | 23 --- src/argaze/DataAnalysis/TimeStampedDataBuffer.py | 34 +++++ src/argaze/DataAnalysis/__init__.py | 2 +- src/argaze/TobiiGlassesPro2/TobiiEntities.py | 22 +-- .../utils/analyse_tobii_segment_fixations.py | 39 ++--- 9 files changed, 231 insertions(+), 245 deletions(-) create mode 100644 src/argaze/DataAnalysis/DictObject.py delete mode 100644 src/argaze/DataAnalysis/FixationsAndSaccades.py create mode 100644 src/argaze/DataAnalysis/GazeAnalysis.py delete mode 100644 src/argaze/DataAnalysis/GenericData.py delete mode 100644 src/argaze/DataAnalysis/TimeStampedData.py create mode 100644 src/argaze/DataAnalysis/TimeStampedDataBuffer.py diff --git a/src/argaze/DataAnalysis/DictObject.py b/src/argaze/DataAnalysis/DictObject.py new file mode 100644 index 0000000..2d6aa66 --- /dev/null +++ b/src/argaze/DataAnalysis/DictObject.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python + +class DictObject(): + """Convert dictionnary into object""" + + def __init__(self, object_type, **dictionnary): + + self.__dict__.update(dictionnary) + self.__type = object_type + + def __getitem__(self, key): + return self.__dict__[key] + + def type(self): + return self.__type + + def keys(self): + return list(self.__dict__.keys())[:-1] + + diff --git a/src/argaze/DataAnalysis/FixationsAndSaccades.py b/src/argaze/DataAnalysis/FixationsAndSaccades.py deleted file mode 100644 index 43bbfc0..0000000 --- a/src/argaze/DataAnalysis/FixationsAndSaccades.py +++ /dev/null @@ -1,166 +0,0 @@ -#!/usr/bin/env python - -import math - -from argaze.DataAnalysis import TimeStampedData - -import numpy - -FIXATION_MAX_DURATION = 1000 - -class Fixation(): - - def __init__(self, start_ts, duration, dispersion, cx, cy): - - self.__start_ts = start_ts - self.__duration = duration - self.__dispersion = dispersion - self.__cx = cx - self.__cy = cy - - def get_start_time(self): - return self.__start_ts - - def get_duration(self): - return self.__duration - - def get_dispersion(self): - return self.__dispersion - - def get_centroid(self): - return (self.__cx, self.__cy) - -class FixationAnalyser(): - - _fixations = [] - _saccades = [] - - def analyse(self, ts_gaze_position): - raise NotImplementedError('analyse() method not implemented') - - def __init__(self, ts_gaze_position): - - # do analysis on a copy - self.analyse(ts_gaze_position.copy()) - - def get_fixations(self): - return self._fixations - - def get_saccades(self): - return self._saccades - -class DispersionBasedFixationAnalyser(FixationAnalyser): - """Implementation of the I-DT 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 - """ - - def __init__(self, ts_gaze_position, dispersion_threshold = 10, duration_threshold = 100): - - self.__dispersion_threshold = dispersion_threshold - self.__duration_threshold = duration_threshold - - super().__init__(ts_gaze_position) - - # euclidian dispersion - def __getEuclideanDispersion(self, ts_gaze_position_list): - - x_list = [gp.x for (ts, gp) in ts_gaze_position_list] - y_list = [gp.y for (ts, gp) in ts_gaze_position_list] - - cx = numpy.mean(x_list) - cy = numpy.mean(y_list) - c = [cx, cy] - - points = numpy.column_stack([x_list, y_list]) - - dist = (points - c)**2 - dist = numpy.sum(dist, axis=1) - dist = numpy.sqrt(dist) - - return max(dist), cx, cy - - # basic dispersion - def __getDispersion(self, ts_gaze_position_list): - - x_list = [gp.x for (ts, gp) in ts_gaze_position_list] - y_list = [gp.y for (ts, gp) in ts_gaze_position_list] - - return (max(x_list) - min(x_list)) + (max(y_list) - min(y_list)) - - def analyse(self, ts_gaze_position): - - # while there are 2 gaze positions at least - while len(ts_gaze_position) >= 2: - - # copy remaining timestamped gaze positions - remaining_ts_gaze_position = ts_gaze_position.copy() - - # select timestamped gaze position until a duration threshold - (ts_start, gaze_position_start) = remaining_ts_gaze_position.popitem() - (ts_current, gaze_position_current) = remaining_ts_gaze_position.popitem() - - ts_gaze_position_list = [(ts_start, gaze_position_start)] - - while (ts_current - ts_start) < self.__duration_threshold: - - ts_gaze_position_list = [(ts_current, gaze_position_current)] - - if len(remaining_ts_gaze_position) > 0: - (ts_current, gaze_position_current) = remaining_ts_gaze_position.popitem() - else: - break - - # how much gaze is dispersed ? - dispersion, cx, cy = self.__getEuclideanDispersion(ts_gaze_position_list) - - # little dispersion - if dispersion <= self.__dispersion_threshold: - - # remove selected gaze positions - for gp in ts_gaze_position_list: - ts_gaze_position.popitem() - - # are next gaze positions not too dispersed ? - while len(remaining_ts_gaze_position) > 0: - - # select next gaze position - ts_gaze_position_list.append(remaining_ts_gaze_position.popitem()) - - new_dispersion, new_cx, new_cy = self.__getEuclideanDispersion(ts_gaze_position_list) - - # dispersion too wide - if new_dispersion > self.__dispersion_threshold: - - # remove last gaze position - ts_gaze_position_list.pop(-1) - break - - # store new dispersion data - dispersion = new_dispersion - cx = new_cx - cy = new_cy - - # remove selected gaze position - ts_gaze_position.popitem() - - # we have a new fixation - ts_list = [ts for (ts, gp) in ts_gaze_position_list] - duration = ts_list[-1] - ts_list[0] - - if duration > FIXATION_MAX_DURATION: - duration = FIXATION_MAX_DURATION - - if duration > 0: - - fixation = Fixation(ts_list[0], duration, dispersion, cx, cy) - - # append fixation - self._fixations.append(fixation) - - # dispersion too wide : consider next gaze position - else: - ts_gaze_position.popitem() diff --git a/src/argaze/DataAnalysis/GazeAnalysis.py b/src/argaze/DataAnalysis/GazeAnalysis.py new file mode 100644 index 0000000..2968b2a --- /dev/null +++ b/src/argaze/DataAnalysis/GazeAnalysis.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python + +import math + +from argaze.DataAnalysis import TimeStampedDataBuffer, DictObject + +import numpy + +FIXATION_MAX_DURATION = 1000 + +class GazePosition(DictObject.DictObject): + """Define gaze position data""" + + def __init__(self, x, y): + + super().__init__(type(self).__name__, **{'x': x, 'y': y}) + +class Fixation(DictObject.DictObject): + """Define fixation data""" + + def __init__(self, duration, dispersion, cx, cy): + + super().__init__(type(self).__name__, **{'duration': duration, 'dispersion': dispersion, 'centroid': [cx, cy]}) + +class FixationAnalyser(): + + fixations = TimeStampedDataBuffer.TimeStampedDataBuffer() + saccades = TimeStampedDataBuffer.TimeStampedDataBuffer() + + def analyse(self, ts_gaze_position_buffer): + raise NotImplementedError('analyse() method not implemented') + + def __init__(self, ts_gaze_position_buffer): + + # do analysis on a copy + self.analyse(ts_gaze_position_buffer.copy()) + +class DispersionBasedFixationAnalyser(FixationAnalyser): + """Implementation of the I-DT 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 + """ + + def __init__(self, ts_gaze_position_buffer, dispersion_threshold = 10, duration_threshold = 100): + + self.__dispersion_threshold = dispersion_threshold + self.__duration_threshold = duration_threshold + + super().__init__(ts_gaze_position_buffer) + + # euclidian dispersion + def __getEuclideanDispersion(self, ts_gaze_position_buffer_list): + + x_list = [gp.x for (ts, gp) in ts_gaze_position_buffer_list] + y_list = [gp.y for (ts, gp) in ts_gaze_position_buffer_list] + + cx = numpy.mean(x_list) + cy = numpy.mean(y_list) + c = [cx, cy] + + points = numpy.column_stack([x_list, y_list]) + + dist = (points - c)**2 + dist = numpy.sum(dist, axis=1) + dist = numpy.sqrt(dist) + + return max(dist), cx, cy + + # basic dispersion + def __getDispersion(self, ts_gaze_position_buffer_list): + + x_list = [gp.x for (ts, gp) in ts_gaze_position_buffer_list] + y_list = [gp.y for (ts, gp) in ts_gaze_position_buffer_list] + + return (max(x_list) - min(x_list)) + (max(y_list) - min(y_list)) + + def analyse(self, ts_gaze_position_buffer): + + # while there are 2 gaze positions at least + while len(ts_gaze_position_buffer) >= 2: + + # copy remaining timestamped gaze positions + remaining_ts_gaze_position_buffer = ts_gaze_position_buffer.copy() + + # select timestamped gaze position until a duration threshold + (ts_start, gaze_position_start) = remaining_ts_gaze_position_buffer.pop_first() + (ts_current, gaze_position_current) = remaining_ts_gaze_position_buffer.pop_first() + + ts_gaze_position_buffer_list = [(ts_start, gaze_position_start)] + + while (ts_current - ts_start) < self.__duration_threshold: + + ts_gaze_position_buffer_list.append( (ts_current, gaze_position_current) ) + + if len(remaining_ts_gaze_position_buffer) > 0: + (ts_current, gaze_position_current) = remaining_ts_gaze_position_buffer.pop_first() + else: + break + + # how much gaze is dispersed ? + dispersion, cx, cy = self.__getEuclideanDispersion(ts_gaze_position_buffer_list) + + + # little dispersion + if dispersion <= self.__dispersion_threshold: + + # remove selected gaze positions + for gp in ts_gaze_position_buffer_list: + ts_gaze_position_buffer.pop_first() + + # are next gaze positions not too dispersed ? + while len(remaining_ts_gaze_position_buffer) > 0: + + # select next gaze position + ts_gaze_position_buffer_list.append(remaining_ts_gaze_position_buffer.pop_first()) + + new_dispersion, new_cx, new_cy = self.__getEuclideanDispersion(ts_gaze_position_buffer_list) + + # dispersion too wide + if new_dispersion > self.__dispersion_threshold: + + # remove last gaze position + ts_gaze_position_buffer_list.pop(-1) + break + + # store new dispersion data + dispersion = new_dispersion + cx = new_cx + cy = new_cy + + # remove selected gaze position + ts_gaze_position_buffer.pop_first() + + # we have a new fixation + ts_list = [ts for (ts, gp) in ts_gaze_position_buffer_list] + duration = ts_list[-1] - ts_list[0] + + if duration > FIXATION_MAX_DURATION: + duration = FIXATION_MAX_DURATION + + if duration > 0: + + # append fixation to timestamped buffer + self.fixations[ts_list[0]] = Fixation(duration, dispersion, cx, cy) + + # dispersion too wide : consider next gaze position + else: + ts_gaze_position_buffer.pop_first() diff --git a/src/argaze/DataAnalysis/GenericData.py b/src/argaze/DataAnalysis/GenericData.py deleted file mode 100644 index 6c72470..0000000 --- a/src/argaze/DataAnalysis/GenericData.py +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env python - -class GenericGazePosition(): - - def get_x(self): - raise NotImplementedError('get_x() method not implemented') - - def get_y(self): - raise NotImplementedError('get_y() method not implemented') - - def __getattr__(self, key): - - if key == 'x': - return self.get_x() - - if key == 'y': - return self.get_y() - - raise NameError(f'{key} is not a valid attribute of {self.__class__}') \ No newline at end of file diff --git a/src/argaze/DataAnalysis/TimeStampedData.py b/src/argaze/DataAnalysis/TimeStampedData.py deleted file mode 100644 index 64f717e..0000000 --- a/src/argaze/DataAnalysis/TimeStampedData.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python - -import collections - -class TimeStampedData(collections.OrderedDict): - """Ordered dictionary to handle timestamped data. - ``` - { - timestamp1: data1, - timestamp2: data2, - ... - } - ``` - """ - - def __new__(cls): - return super(TimeStampedData, cls).__new__(cls) - - def __init__(self): - pass - - def __del__(self): - pass \ No newline at end of file diff --git a/src/argaze/DataAnalysis/TimeStampedDataBuffer.py b/src/argaze/DataAnalysis/TimeStampedDataBuffer.py new file mode 100644 index 0000000..23bcf06 --- /dev/null +++ b/src/argaze/DataAnalysis/TimeStampedDataBuffer.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python + +import collections + +class TimeStampedDataBuffer(collections.OrderedDict): + """Ordered dictionary to handle timestamped data. + ``` + { + timestamp1: data1, + timestamp2: data2, + ... + } + ``` + """ + + def __new__(cls): + return super(TimeStampedDataBuffer, cls).__new__(cls) + + def __init__(self): + pass + + def __del__(self): + pass + + def __setitem__(self, key: float, value): + """Force key to be a float""" + if type(key) != float: + raise KeyError('key must be a float') + + super().__setitem__(key, value) + + def pop_first(self): + """Easing FIFO access mode""" + return self.popitem(last=False) \ No newline at end of file diff --git a/src/argaze/DataAnalysis/__init__.py b/src/argaze/DataAnalysis/__init__.py index 1181cab..ff8fb6f 100644 --- a/src/argaze/DataAnalysis/__init__.py +++ b/src/argaze/DataAnalysis/__init__.py @@ -2,4 +2,4 @@ .. include:: README.md """ __docformat__ = "restructuredtext" -__all__ = ['TimeStampedData','GenericData','FixationsAndSaccades'] \ No newline at end of file +__all__ = ['TimeStampedDataBuffer','DictObject','GazeAnalysis'] \ No newline at end of file diff --git a/src/argaze/TobiiGlassesPro2/TobiiEntities.py b/src/argaze/TobiiGlassesPro2/TobiiEntities.py index a16700d..e41bbc9 100644 --- a/src/argaze/TobiiGlassesPro2/TobiiEntities.py +++ b/src/argaze/TobiiGlassesPro2/TobiiEntities.py @@ -5,7 +5,7 @@ import json import gzip import os -from argaze.DataAnalysis import TimeStampedData +from argaze.DataAnalysis import TimeStampedDataBuffer, DictObject import cv2 as cv @@ -39,7 +39,7 @@ class TobiiSegmentData: def load(self): - ts_data_dict = {} + ts_data_buffer_dict = {} # define a decoder function def decode(json_item): @@ -60,22 +60,24 @@ class TobiiSegmentData: if ts < 0: return - # concatenate keys to get data signature - data_signature = '-'.join(json_item.keys()) + # convert json data into data object + data_object_type = '-'.join(json_item.keys()) + data_object = DictObject.DictObject(data_object_type, **json_item) - # append a timestamped data buffer to store all data with same signature - if data_signature not in ts_data_dict.keys(): - ts_data_dict[data_signature] = TimeStampedData.TimeStampedData() + # append a dedicated timestamped buffer for each data object type + if data_object.type() not in ts_data_buffer_dict.keys(): + ts_data_buffer_dict[data_object.type()] = TimeStampedDataBuffer.TimeStampedDataBuffer() - # store data into the dedicated timestamped data buffer - ts_data_dict[data_signature][ts] = json_item + # store data object into the timestamped buffer dedicated to its type + ts_data_buffer_dict[data_object.type()][ts] = data_object # start loading with gzip.open(self.__segment_data_path) as f: + for item in f: json.loads(item.decode('utf-8'), object_hook=decode) - return ts_data_dict + return ts_data_buffer_dict class TobiiSegmentVideo: """Handle Tobii Glasses Pro 2 segment video file.""" diff --git a/src/argaze/utils/analyse_tobii_segment_fixations.py b/src/argaze/utils/analyse_tobii_segment_fixations.py index ee9b0b9..dcd38ea 100644 --- a/src/argaze/utils/analyse_tobii_segment_fixations.py +++ b/src/argaze/utils/analyse_tobii_segment_fixations.py @@ -25,41 +25,28 @@ def main(): # Load a tobii segment video tobii_segment_video = tobii_segment.get_video() print(f'Video width: {tobii_segment_video.get_width()}, height: {tobii_segment_video.get_height()}, fps: {tobii_segment_video.get_fps()}') + + # Load a tobii segment timestamped gaze position data buffer + tobii_ts_gaze_position_buffer = tobii_segment.get_data().load()['gidx-l-gp'] - # Load a tobii segment timestamped gaze position data - tobii_ts_gaze_position = tobii_segment.get_data().load()['gidx-l-gp'] + print(f'{len(tobii_ts_gaze_position_buffer)} gaze positions loaded') - print(f'{len(tobii_ts_gaze_position)} gaze positions loaded') + # format tobii gaze data into generic gaze data + generic_ts_gaze_position_buffer = TimeStampedDataBuffer.TimeStampedDataBuffer() - # Define Tobii gaze data to Generic gaze data translater - class TobiiGazePositionTranslater(GenericData.GenericGazePosition): - - def __init__(self, tobii_gaze_position, tobii_segment_video): - - self.__tobii_gaze_position = tobii_gaze_position - self.__tobii_segment_video = tobii_segment_video - - def get_x(self): - return self.__tobii_gaze_position['gp'][0] * self.__tobii_segment_video.get_width() - - def get_y(self): - return self.__tobii_gaze_position['gp'][1] * self.__tobii_segment_video.get_height() - - generic_ts_gaze_position = TimeStampedData.TimeStampedData() - - while len(tobii_ts_gaze_position): - ts, item = tobii_ts_gaze_position.popitem() - generic_ts_gaze_position[ts] = TobiiGazePositionTranslater(item, tobii_segment_video) + for ts, tobii_data in tobii_ts_gaze_position_buffer.items(): + generic_data = GazeAnalysis.GazePosition(tobii_data.gp[0] * tobii_segment_video.get_width(), tobii_data.gp[1] * tobii_segment_video.get_height()) + generic_ts_gaze_position_buffer[ts] = generic_data print(f'dispersion_threshold = {args.dispersion_threshold}') print(f'duration_threshold = {args.duration_threshold}') - fixation_analyser = FixationsAndSaccades.DispersionBasedFixationAnalyser(generic_ts_gaze_position, args.dispersion_threshold, args.duration_threshold) + fixation_analyser = GazeAnalysis.DispersionBasedFixationAnalyser(generic_ts_gaze_position_buffer, args.dispersion_threshold, args.duration_threshold) - print(f'{len(fixation_analyser.get_fixations())} fixations found') + print(f'{len(fixation_analyser.fixations)} fixations found') - for f in fixation_analyser.get_fixations(): - print(f'start time = {f.get_start_time()}, duration = {f.get_duration()}, dispertion = {f.get_dispersion()}, centroid = {f.get_centroid()}') + for ts, f in fixation_analyser.fixations.items(): + print(f'start time = {ts}, duration = {f.duration}, dispertion = {f.dispersion}, centroid = {f.centroid}') if __name__ == '__main__': -- cgit v1.1