aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorThéo de la Hogue2022-12-13 18:43:14 +0100
committerThéo de la Hogue2022-12-13 18:43:14 +0100
commit130ed1bff4df87b1be2b5ff1f0333e3b4cb93383 (patch)
tree1fe18696741164c7028cfd9fc4d5f1d89ac09cea
parent0e128917d7a17585f971e04d0345ca08839cf4bd (diff)
downloadargaze-130ed1bff4df87b1be2b5ff1f0333e3b4cb93383.zip
argaze-130ed1bff4df87b1be2b5ff1f0333e3b4cb93383.tar.gz
argaze-130ed1bff4df87b1be2b5ff1f0333e3b4cb93383.tar.bz2
argaze-130ed1bff4df87b1be2b5ff1f0333e3b4cb93383.tar.xz
Improving fixation detection and fixation overlap.
-rw-r--r--src/argaze/GazeAnalysis/DispersionBasedGazeMovementIdentifier.py116
1 files changed, 62 insertions, 54 deletions
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()