From 130ed1bff4df87b1be2b5ff1f0333e3b4cb93383 Mon Sep 17 00:00:00 2001 From: Théo de la Hogue Date: Tue, 13 Dec 2022 18:43:14 +0100 Subject: Improving fixation detection and fixation overlap. --- .../DispersionBasedGazeMovementIdentifier.py | 116 +++++++++++---------- 1 file changed, 62 insertions(+), 54 deletions(-) (limited to 'src') diff --git a/src/argaze/GazeAnalysis/DispersionBasedGazeMovementIdentifier.py b/src/argaze/GazeAnalysis/DispersionBasedGazeMovementIdentifier.py index 94b6357..19f7fef 100644 --- a/src/argaze/GazeAnalysis/DispersionBasedGazeMovementIdentifier.py +++ b/src/argaze/GazeAnalysis/DispersionBasedGazeMovementIdentifier.py @@ -17,67 +17,42 @@ class Fixation(GazeFeatures.Fixation): deviation_max: float = field(init=False) """Maximal gaze position distance to the centroïd.""" - deviation_mean: float = field(init=False) - """Average of gaze position distances to the centroïd.""" - - euclidian: bool = field(default=True) - """Does the distance is calculated in euclidian way.""" - def __post_init__(self): super().__post_init__() self.update() - def update(self): - - x_list = [gp[0] for (_, gp) in list(self.positions.items())] - y_list = [gp[1] for (_, gp) in list(self.positions.items())] - - cx = numpy.mean(x_list) - cy = numpy.mean(y_list) - - # Select dispersion algorithm - if self.euclidian: - - c = [cx, cy] - points = numpy.column_stack([x_list, y_list]) + def point_deviation(self, gaze_position) -> float: + """Get distance of a point from the fixation's centroïd.""" - dist = (points - c)**2 - dist = numpy.sum(dist, axis=1) - dist = numpy.sqrt(dist) + return numpy.sqrt((self.centroid[0] - gaze_position.value[0])**2 + (self.centroid[1] - gaze_position.value[1])**2) - __deviation_max = max(dist) - __deviation_mean = numpy.mean(dist) - - else: + def update(self): + """Update fixation's centroïd then maximal gaze positions deviation from this centroïd.""" - __deviation_max = (max(x_list) - min(x_list)) + (max(y_list) - min(y_list)) + 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 centroid attribute - object.__setattr__(self, 'centroid', (cx, cy)) + object.__setattr__(self, 'centroid', (centroid_array[0], centroid_array[1])) # Update frozen deviation_max attribute - object.__setattr__(self, 'deviation_max', __deviation_max) - - # Update frozen deviation_mean attribute - object.__setattr__(self, 'deviation_mean', __deviation_mean) - - def overlap(self, fixation) -> float: - """Does this fixation overlap another fixation?""" - - dist = (self.centroid[0] - fixation.centroid[0])**2 + (self.centroid[1] - fixation.centroid[1])**2 - dist = numpy.sqrt(dist) - - return dist <= (self.deviation_max + fixation.deviation_max) + object.__setattr__(self, 'deviation_max', max(deviations_array)) - def contains_point(self, gaze_position) -> bool: - """Is a point inside fixation?""" + def overlap(self, fixation) -> list: + """Does a gaze position from another fixation have a deviation to this fixation centroïd smaller than maximal deviation?""" - dist = (self.centroid[0] - gaze_position[0])**2 + (self.centroid[1] - gaze_position[1])**2 - dist = numpy.sqrt(dist) + 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 dist <= self.deviation_max + return min(deviations_array) <= self.deviation_max def merge(self, fixation) -> float: """Merge another fixation into this fixation.""" @@ -124,6 +99,9 @@ class GazeMovementIdentifier(GazeFeatures.GazeMovementIdentifier): while not gaze_position_current.valid and len(self.__ts_gaze_positions) > 0: ts_current, gaze_position_current = self.__ts_gaze_positions.pop_first() + # Prepare to select current and next valid gaze positions until a duration threshold + valid_gaze_positions = GazeFeatures.TimeStampedGazePositions() + # Output last fixation after too much unvalid positons if self.__last_fixation != None: @@ -131,15 +109,27 @@ class GazeMovementIdentifier(GazeFeatures.GazeMovementIdentifier): if (ts_current - ts_last) > self.duration_min_threshold: + # Get last gaze position of the last fixation as it is it's out position + last_new_ts, last_new_position = self.__last_fixation.positions.pop_last() + + # Update last fixation + self.__last_fixation.update() + + # Append this last out position to the valid gaze positions selection + valid_gaze_positions[last_new_ts] = last_new_position + yield self.__last_fixation + self.__last_fixation = None - # Select current and next valid gaze positions until a duration threshold - valid_gaze_positions = GazeFeatures.TimeStampedGazePositions() + # Append current gaze position to valid gaze positions selection valid_gaze_positions[ts_current] = gaze_position_current # Store unvalid gaze positions to count them unvalid_gaze_positions = GazeFeatures.TimeStampedGazePositions() + # Keep track of last valid timestamp + ts_last_valid = ts_current + for ts_next, gaze_position_next in self.__ts_gaze_positions.items(): if (ts_next - ts_current) < self.duration_min_threshold: @@ -149,6 +139,9 @@ class GazeMovementIdentifier(GazeFeatures.GazeMovementIdentifier): valid_gaze_positions[ts_next] = gaze_position_next + # Keep track of last valid timestamp + ts_last_valid = ts_next + # Store non valid position else: @@ -158,8 +151,11 @@ class GazeMovementIdentifier(GazeFeatures.GazeMovementIdentifier): break - # If there is at least 2 valid gaze positions selected - if len(valid_gaze_positions) >= 2: + # If there is at least 3 valid gaze positions selected: + # - 1 entering position + # - 1 staying position + # - 1 outing position (which may become a staying position after extension) + if len(valid_gaze_positions) >= 3: # Consider selected valid gaze positions as part of a maybe new fixation new_fixation = Fixation(valid_gaze_positions) @@ -185,17 +181,22 @@ class GazeMovementIdentifier(GazeFeatures.GazeMovementIdentifier): # Consider only valid next position if gaze_position_next.valid: - extended_gaze_positions[ts_next] = gaze_position_next + # Get deviation of the nex gaze position from the new extended fixation + deviation_from_extended = extended_fixation.point_deviation(gaze_position_next) - # How much extended fixation is dispersed ? + # Extend fixation anyway even with last out position: it will be popped later. + extended_gaze_positions[ts_next] = gaze_position_next extended_fixation = Fixation(extended_gaze_positions) - # Dispersion is too wide : break - if extended_fixation.deviation_max > self.deviation_max_threshold: + # Stop fixation extension + if deviation_from_extended > self.deviation_max_threshold: break - # NOTE: The last extended position is out of the fixation : this position will be popped later + # Check that consecutive unvalid gaze positions do not exceed fixation duration threshold + elif ts_next - ts_last_valid >= self.duration_min_threshold: + + break # Update new fixation new_fixation = extended_fixation @@ -209,6 +210,9 @@ class GazeMovementIdentifier(GazeFeatures.GazeMovementIdentifier): start_movement_ts, start_position = self.__last_fixation.positions.pop_last() stop_movement_ts, stop_position = new_fixation.positions.pop_first() + # Remove last gaze position of the new fixation as it is it's out position + last_new_ts, last_new_position = new_fixation.positions.pop_last() + # Rare case : the last fixation position is the same than the first position of the new fixation if start_movement_ts == stop_movement_ts: start_movement_ts, start_position = self.__last_fixation.positions.pop_last() @@ -245,6 +249,7 @@ class GazeMovementIdentifier(GazeFeatures.GazeMovementIdentifier): movement_gaze_positions[stop_movement_ts] = stop_position # End of inter fixations movement edition + #print(f'Inter: <{start_movement_ts} {stop_movement_ts}>, duration: {stop_movement_ts - start_movement_ts}, length: {len(movement_gaze_positions)}') # Does new fixation overlap last fixation? if self.__last_fixation.overlap(new_fixation): @@ -281,6 +286,9 @@ class GazeMovementIdentifier(GazeFeatures.GazeMovementIdentifier): # Output unknown movement yield GazeFeatures.GazeMovement(movement_gaze_positions) + # Append out position to last fixation: it will be popped as start_position the next time + self.__last_fixation.positions[last_new_ts] = last_new_position + # In any case, forget former unmatched gaze positions unmatched_gaze_positions = GazeFeatures.TimeStampedGazePositions() -- cgit v1.1