"""Module for matching algorithm based on fixation's deviation circle coverage over AOI. 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 math from argaze import GazeFeatures, DataFeatures from argaze.AreaOfInterest import AOIFeatures import numpy import cv2 class AOIMatcher(GazeFeatures.AOIMatcher): """Matching algorithm based on fixation's deviation circle coverage over AOI.""" @DataFeatures.PipelineStepInit def __init__(self, **kwargs): # Init AOIMatcher class super().__init__() self.__coverage_threshold = 0 self.__reset() @property def coverage_threshold(self) -> float: """Minimal coverage ratio to consider a fixation over an AOI (1 means that whole fixation's deviation circle have to be over the AOI).""" return self.__coverage_threshold @coverage_threshold.setter def coverage_threshold(self, coverage_threshold: float): self.__coverage_threshold = coverage_threshold def __reset(self): self.__look_count = 0 self.__looked_aoi_data = (None, None) self.__looked_probabilities = {} self.__circle_ratio_sum = {} self.__matched_gaze_movement = None self.__matched_region = None @DataFeatures.PipelineStepMethod def match(self, gaze_movement, aoi_scene) -> tuple[str, AOIFeatures.AreaOfInterest]: """Returns AOI with the maximal fixation's deviation circle coverage if above coverage threshold.""" if GazeFeatures.is_fixation(gaze_movement): self.__look_count += 1 max_coverage = 0. most_likely_looked_aoi_data = (None, None) matched_region = None for name, aoi in aoi_scene.items(): # BAD: we use deviation_max attribute which is an attribute of DispersionThresholdIdentification.Fixation class region, _, circle_ratio = aoi.circle_intersection(gaze_movement.focus, gaze_movement.deviation_max) if name not in self.exclude and circle_ratio > self.__coverage_threshold: # Sum circle ratio to update aoi coverage try: self.__circle_ratio_sum[name] += circle_ratio except KeyError: self.__circle_ratio_sum[name] = circle_ratio # Update maximal coverage and most likely looked aoi if self.__circle_ratio_sum[name] > max_coverage: max_coverage = self.__circle_ratio_sum[name] most_likely_looked_aoi_data = (name, aoi) matched_region = region # Check that aoi coverage happens if max_coverage > 0: # Update looked aoi data self.__looked_aoi_data = most_likely_looked_aoi_data # Calculate circle ratio means as looked probabilities self.__looked_probabilities = {} for aoi_name, circle_ratio_sum in self.__circle_ratio_sum.items(): circle_ratio_mean = circle_ratio_sum / self.__look_count # Avoid probability greater than 1 self.__looked_probabilities[aoi_name] = circle_ratio_mean if circle_ratio_mean < 1 else 1 # Update matched gaze movement self.__matched_gaze_movement = gaze_movement # Update matched region self.__matched_region = matched_region # Return return self.__looked_aoi_data elif GazeFeatures.is_saccade(gaze_movement): self.__reset() elif not gaze_movement: self.__reset() return (None, None) def draw(self, image: numpy.array, aoi_scene: AOIFeatures.AOIScene, draw_matched_fixation: dict = None, draw_matched_region: dict = None, draw_looked_aoi: dict = None, update_looked_aoi: bool = False, looked_aoi_name_color: tuple = None, looked_aoi_name_offset: tuple = (0, 0)): """Draw matching into image. Parameters: image: where to draw aoi_scene: to refresh looked aoi if required draw_matched_fixation: Fixation.draw parameters (which depends of the loaded gaze movement identifier module, if None, no fixation is drawn) draw_matched_region: AOIFeatures.AOI.draw parameters (if None, no matched region is drawn) draw_looked_aoi: AOIFeatures.AOI.draw parameters (if None, no looked aoi is drawn) looked_aoi_name_color: color of text (if None, no looked aoi name is drawn) looked_aoi_name_offset: ofset of text from the upper left aoi bounding box corner """ if self.__matched_gaze_movement is not None: if GazeFeatures.is_fixation(self.__matched_gaze_movement): # Draw matched fixation if required if draw_matched_fixation is not None: self.__matched_gaze_movement.draw(image, **draw_matched_fixation) # Draw matched aoi if self.looked_aoi().all() is not None: if update_looked_aoi: try: self.__looked_aoi_data = (self.looked_aoi_name(), aoi_scene[self.looked_aoi_name()]) except KeyError: pass # Draw looked aoi if required if draw_looked_aoi is not None: self.looked_aoi().draw(image, **draw_looked_aoi) # Draw matched region if required if draw_matched_region is not None: self.__matched_region.draw(image, **draw_matched_region) # Draw looked aoi name if required if looked_aoi_name_color is not None: top_left_corner_pixel = numpy.rint(self.looked_aoi().bounding_box[0]).astype(int) + looked_aoi_name_offset cv2.putText(image, self.looked_aoi_name(), top_left_corner_pixel, cv2.FONT_HERSHEY_SIMPLEX, 1, looked_aoi_name_color, 1, cv2.LINE_AA) def looked_aoi(self) -> AOIFeatures.AreaOfInterest: """Get most likely looked aoi for current fixation (e.g. the aoi with the highest coverage mean value)""" return self.__looked_aoi_data[1] def looked_aoi_name(self) -> str: """Get most likely looked aoi name for current fixation (e.g. the aoi with the highest coverage mean value)""" return self.__looked_aoi_data[0] def looked_probabilities(self) -> dict: """Get probabilities to be looked by current fixation for each aoi. !!! note aoi where fixation deviation circle never passed the coverage threshold will be missing. """ return self.__looked_probabilities