def _load_from_dict(self, init_dict):
        super()._load_from_dict(init_dict)
        try:
            cache = init_dict["cache"]
            for cache_idx in range(len(cache)):
                location = cache[cache_idx]
                cache[
                    cache_idx] = Surface_Location.load_from_serializable_copy(
                        location)

            self.location_cache = Cache(cache)
        except (KeyError, TypeError):
            self.location_cache = None

        try:
            added_in_player = init_dict["added_in_player"]
        except KeyError:
            # If surface was created in Capture, we just accept it as is
            self.observations_frame_idxs = []
            self.start_idx = 0
            self.build_up_status = 1.0
        else:
            self.observations_frame_idxs = added_in_player[
                "observations_frame_idxs"]
            self.start_idx = added_in_player["start_idx"]
 def _filter_marker_cache(self, cache_to_filter):
     marker_cache = []
     for markers in cache_to_filter:
         if markers:
             markers = self._filter_markers(markers)
         marker_cache.append(markers)
     return Cache(marker_cache)
Example #3
0
    def move_corner(self, frame_idx, marker_cache, corner_idx, new_pos, camera_model):
        super().move_corner(corner_idx, new_pos, camera_model)

        # Reset of marker cache. This does not invoke a recalculation in the background.
        # Full recalculation will happen once the surface corner was released.
        self.location_cache = Cache([None for _ in marker_cache])
        self.update_location_cache(frame_idx, marker_cache, camera_model)
    def _recalculate_marker_cache(self, previous_state=None):
        if previous_state is None:
            previous_state = [None for _ in self.g_pool.timestamps]

            # If we had a previous_state argument, surface objects had just been
            # initialized with their previous state, which we do not want to overwrite.
            # Therefore resetting the marker cache is only done when no previous_state
            # is defined.
            for surface in self.surfaces:
                surface.location_cache = None

        self.marker_cache_unfiltered = Cache(previous_state)
        self.marker_cache = self._filter_marker_cache(self.marker_cache_unfiltered)

        self.cache_filler = background_tasks.background_video_processor(
            self.g_pool.capture.source_path,
            offline_utils.marker_detection_callable(
                self.CACHE_MIN_MARKER_PERIMETER, self.inverted_markers
            ),
            list(self.marker_cache),
            self.cache_seek_idx,
            mp_context,
        )
Example #5
0
    def _recalculate_location_cache(self, frame_idx, marker_cache, camera_model):
        logging.debug("Recalculate Surface Cache!")
        if self.location_cache_filler is not None:
            self.location_cache_filler.cancel()

        # Reset cache and recalculate.
        self.cache_seek_idx.value = frame_idx
        self.location_cache = Cache([None for _ in marker_cache])
        self.location_cache_filler = background_tasks.background_data_processor(
            marker_cache,
            offline_utils.surface_locater_callable(
                camera_model,
                self.registered_markers_undist,
                self.registered_markers_dist,
            ),
            self.cache_seek_idx,
            mp_context,
        )
    def _recalculate_marker_cache(self, previous_state=None):
        if previous_state is None:
            previous_state = [None for _ in self.g_pool.timestamps]

            # If we had a previous_state argument, surface objects had just been
            # initialized with their previous state, which we do not want to overwrite.
            # Therefore resetting the marker cache is only done when no previous_state
            # is defined.
            for surface in self.surfaces:
                surface.location_cache = None

        self.marker_cache_unfiltered = Cache(previous_state)
        self.marker_cache = self._filter_marker_cache(self.marker_cache_unfiltered)

        self.cache_filler = background_tasks.background_video_processor(
            self.g_pool.capture.source_path,
            offline_utils.marker_detection_callable(
                self.CACHE_MIN_MARKER_PERIMETER, self.inverted_markers
            ),
            list(self.marker_cache),
            self.cache_seek_idx,
            mp_context,
        )
class Surface_Tracker_Offline(Surface_Tracker, Analysis_Plugin_Base):
    """
    The Surface_Tracker_Offline does marker based AOI tracking in a recording. All
    marker and surface detections are calculated in the background and cached to reduce
    computation.
    """

    order = 0.2
    TIMELINE_LINE_HEIGHT = 16

    def __init__(self, g_pool, marker_min_perimeter=60, inverted_markers=False):
        super().__init__(g_pool, marker_min_perimeter, inverted_markers)

        self.MARKER_CACHE_VERSION = 3
        # Also add very small detected markers to cache and filter cache afterwards
        self.CACHE_MIN_MARKER_PERIMETER = 20
        self.cache_seek_idx = mp_context.Value("i", 0)
        self.marker_cache = None
        self.marker_cache_unfiltered = None
        self.cache_filler = None
        self._init_marker_cache()
        self.last_cache_update_ts = time.time()
        self.CACHE_UPDATE_INTERVAL_SEC = 5

        self.gaze_on_surf_buffer = None
        self.gaze_on_surf_buffer_filler = None

        self._heatmap_update_requests = set()
        self.export_proxies = set()

    @property
    def Surface_Class(self):
        return Surface_Offline

    @property
    def _save_dir(self):
        return self.g_pool.rec_dir

    @property
    def has_freeze_feature(self):
        return False

    @property
    def ui_info_text(self):
        return (
            "The offline surface tracker will look for markers in the entire "
            "video. By default it uses surfaces defined in capture. You can "
            "change and add more surfaces here. \n \n Press the export button or "
            "type 'e' to start the export."
        )

    @property
    def supported_heatmap_modes(self):
        return [Heatmap_Mode.WITHIN_SURFACE, Heatmap_Mode.ACROSS_SURFACES]

    def _init_marker_cache(self):
        previous_cache = file_methods.Persistent_Dict(
            os.path.join(self.g_pool.rec_dir, "square_marker_cache")
        )
        version = previous_cache.get("version", 0)
        cache = previous_cache.get("marker_cache_unfiltered", None)

        if cache is None:
            self._recalculate_marker_cache()
        elif version != self.MARKER_CACHE_VERSION:
            logger.debug("Marker cache version missmatch. Rebuilding marker cache.")
            self.inverted_markers = previous_cache.get("inverted_markers", False)
            self._recalculate_marker_cache()
        else:
            marker_cache_unfiltered = []
            for markers in cache:
                # Loaded markers are either False, [] or a list of dictionaries. We
                # need to convert the dictionaries into Square_Marker_Detection objects.
                if markers:

                    markers = [
                        Square_Marker_Detection(*args) if args else None
                        for args in markers
                    ]
                marker_cache_unfiltered.append(markers)

            self._recalculate_marker_cache(previous_state=marker_cache_unfiltered)
            self.inverted_markers = previous_cache.get("inverted_markers", False)
            logger.debug("Restored previous marker cache.")

    def _recalculate_marker_cache(self, previous_state=None):
        if previous_state is None:
            previous_state = [None for _ in self.g_pool.timestamps]

            # If we had a previous_state argument, surface objects had just been
            # initialized with their previous state, which we do not want to overwrite.
            # Therefore resetting the marker cache is only done when no previous_state
            # is defined.
            for surface in self.surfaces:
                surface.location_cache = None

        self.marker_cache_unfiltered = Cache(previous_state)
        self.marker_cache = self._filter_marker_cache(self.marker_cache_unfiltered)

        self.cache_filler = background_tasks.background_video_processor(
            self.g_pool.capture.source_path,
            offline_utils.marker_detection_callable(
                self.CACHE_MIN_MARKER_PERIMETER, self.inverted_markers
            ),
            list(self.marker_cache),
            self.cache_seek_idx,
            mp_context,
        )

    def _filter_marker_cache(self, cache_to_filter):
        marker_cache = []
        for markers in cache_to_filter:
            if markers:
                markers = self._filter_markers(markers)
            marker_cache.append(markers)
        return Cache(marker_cache)

    def init_ui(self):
        super().init_ui()

        self.glfont = pyglui.pyfontstash.fontstash.Context()
        self.glfont.add_font("opensans", pyglui.ui.get_opensans_font_path())
        self.glfont.set_color_float((1.0, 1.0, 1.0, 0.8))
        self.glfont.set_align_string(v_align="right", h_align="top")

        self.timeline = pyglui.ui.Timeline(
            "Surface Tracker",
            self._gl_display_cache_bars,
            self._draw_labels,
            self.TIMELINE_LINE_HEIGHT * (len(self.surfaces) + 1),
        )
        self.g_pool.user_timelines.append(self.timeline)
        self.timeline.content_height = (
            len(self.surfaces) + 1
        ) * self.TIMELINE_LINE_HEIGHT

    def recent_events(self, events):
        super().recent_events(events)
        self._fetch_data_from_bg_fillers()

    def _fetch_data_from_bg_fillers(self):
        if self.gaze_on_surf_buffer_filler is not None:
            for gaze in self.gaze_on_surf_buffer_filler.fetch():
                self.gaze_on_surf_buffer.append(gaze)

            if self.gaze_on_surf_buffer_filler.completed:
                self.gaze_on_surf_buffer_filler = None
                self._update_surface_heatmaps()
                self.gaze_on_surf_buffer = None

        for proxy in list(self.export_proxies):
            for _ in proxy.fetch():
                pass

            if proxy.completed:
                self.export_proxies.remove(proxy)

    def _update_markers(self, frame):
        self._update_marker_and_surface_caches()

        self.markers = self.marker_cache[frame.index]
        self.markers_unfiltered = self.marker_cache_unfiltered[frame.index]
        if self.markers is None:
            # Move seek index to current frame because caches do not contain data for it
            self.markers = []
            self.markers_unfiltered = []
            self.cache_seek_idx.value = frame.index

    def _update_marker_and_surface_caches(self):
        if self.cache_filler is None:
            return

        for frame_index, markers in self.cache_filler.fetch():
            if frame_index is None:
                continue
            markers = self._remove_duplicate_markers(markers)
            self.marker_cache_unfiltered.update(frame_index, markers)
            markers_filtered = self._filter_markers(markers)
            self.marker_cache.update(frame_index, markers_filtered)

            for surface in self.surfaces:
                surface.update_location_cache(
                    frame_index, self.marker_cache, self.camera_model
                )

        if self.cache_filler.completed:
            self.cache_filler = None
            for surface in self.surfaces:
                self._heatmap_update_requests.add(surface)
            self._fill_gaze_on_surf_buffer()
            self._save_marker_cache()
            self.save_surface_definitions_to_file()

        now = time.time()
        if now - self.last_cache_update_ts > self.CACHE_UPDATE_INTERVAL_SEC:
            self._save_marker_cache()
            self.last_cache_update_ts = now

    def _update_surface_locations(self, frame_index):
        for surface in self.surfaces:
            surface.update_location(frame_index, self.marker_cache, self.camera_model)

    def _update_surface_corners(self):
        for surface, corner_idx in self._edit_surf_verts:
            if surface.detected:
                surface.move_corner(
                    self.current_frame.index,
                    self.marker_cache,
                    corner_idx,
                    self._last_mouse_pos.copy(),
                    self.camera_model,
                )

    def _update_surface_heatmaps(self):
        self._compute_across_surfaces_heatmap()

        for surface in self._heatmap_update_requests:
            surf_idx = self.surfaces.index(surface)
            gaze_on_surf = self.gaze_on_surf_buffer[surf_idx]
            gaze_on_surf = list(itertools.chain.from_iterable(gaze_on_surf))
            surface.update_heatmap(gaze_on_surf)

        self._heatmap_update_requests.clear()

    def _compute_across_surfaces_heatmap(self):
        gaze_counts_per_surf = []
        for gaze in self.gaze_on_surf_buffer:
            gaze = list(itertools.chain.from_iterable(gaze))
            gaze = [g for g in gaze if g["on_surf"]]
            gaze_counts_per_surf.append(len(gaze))

        if gaze_counts_per_surf:
            max_count = max(gaze_counts_per_surf)
            results = np.array(gaze_counts_per_surf, dtype=np.float32)
            if max_count > 0:
                results *= 255.0 / max_count
            results = np.uint8(results)
            results_color_maps = cv2.applyColorMap(results, cv2.COLORMAP_JET)

            for surface, color_map in zip(self.surfaces, results_color_maps):
                heatmap = np.ones((1, 1, 4), dtype=np.uint8) * 125
                heatmap[:, :, :3] = color_map
                surface.across_surface_heatmap = heatmap
        else:
            for surface in self.surfaces:
                surface.across_surface_heatmap = surface.get_uniform_heatmap()

    def _fill_gaze_on_surf_buffer(self):
        in_mark = self.g_pool.seek_control.trim_left
        out_mark = self.g_pool.seek_control.trim_right
        section = slice(in_mark, out_mark)

        all_world_timestamps = self.g_pool.timestamps
        all_gaze_events = self.g_pool.gaze_positions

        self._start_gaze_buffer_filler(all_gaze_events, all_world_timestamps, section)

    def _start_gaze_buffer_filler(self, all_gaze_events, all_world_timestamps, section):
        if self.gaze_on_surf_buffer_filler is not None:
            self.gaze_on_surf_buffer_filler.cancel()
        self.gaze_on_surf_buffer = []
        self.gaze_on_surf_buffer_filler = background_tasks.background_gaze_on_surface(
            self.surfaces,
            section,
            all_world_timestamps,
            all_gaze_events,
            self.camera_model,
            mp_context,
        )

    def gl_display(self):
        if self.timeline:
            self.timeline.refresh()
        super().gl_display()

    def _gl_display_cache_bars(self, width, height, scale):
        ts = self.g_pool.timestamps
        with gl_utils.Coord_System(ts[0], ts[-1], height, 0):
            # Lines for areas that have been cached
            cached_ranges = []
            for r in self.marker_cache.visited_ranges:
                cached_ranges += ((ts[r[0]], 0), (ts[r[1]], 0))

            gl.glTranslatef(0, scale * self.TIMELINE_LINE_HEIGHT / 2, 0)
            color = pyglui_utils.RGBA(0.8, 0.2, 0.2, 0.8)
            pyglui_utils.draw_polyline(
                cached_ranges, color=color, line_type=gl.GL_LINES, thickness=scale * 4
            )
            cached_ranges = []
            for r in self.marker_cache.positive_ranges:
                cached_ranges += ((ts[r[0]], 0), (ts[r[1]], 0))

            color = pyglui_utils.RGBA(0, 0.7, 0.3, 0.8)
            pyglui_utils.draw_polyline(
                cached_ranges, color=color, line_type=gl.GL_LINES, thickness=scale * 4
            )

            # Lines where surfaces have been found in video
            cached_surfaces = []
            for surface in self.surfaces:
                found_at = []
                if surface.location_cache is not None:
                    for r in surface.location_cache.positive_ranges:  # [[0,1],[3,4]]
                        found_at += ((ts[r[0]], 0), (ts[r[1]], 0))
                cached_surfaces.append(found_at)

            color = pyglui_utils.RGBA(0, 0.7, 0.3, 0.8)

            for surface in cached_surfaces:
                gl.glTranslatef(0, scale * self.TIMELINE_LINE_HEIGHT, 0)
                pyglui_utils.draw_polyline(
                    surface, color=color, line_type=gl.GL_LINES, thickness=scale * 2
                )

    def _draw_labels(self, width, height, scale):
        self.glfont.set_size(self.TIMELINE_LINE_HEIGHT * 0.8 * scale)
        self.glfont.draw_text(width, 0, "Marker Cache")
        for surface in self.surfaces:
            gl.glTranslatef(0, self.TIMELINE_LINE_HEIGHT * scale, 0)
            self.glfont.draw_text(width, 0, surface.name)

    def add_surface(self, init_dict=None):
        super().add_surface(init_dict)

        try:
            self.timeline.content_height += self.TIMELINE_LINE_HEIGHT
            self._fill_gaze_on_surf_buffer()
        except AttributeError:
            pass
        self.surfaces[-1].on_surface_change = self.on_surface_change

    def remove_surface(self, surface):
        super().remove_surface(surface)
        try:
            self._heatmap_update_requests.remove(surface)
        except KeyError:
            pass
        self.timeline.content_height -= self.TIMELINE_LINE_HEIGHT

    def on_notify(self, notification):
        super().on_notify(notification)

        if notification["subject"] == "surface_tracker.marker_detection_params_changed":
            self._recalculate_marker_cache()

        elif notification["subject"] == "surface_tracker.marker_min_perimeter_changed":
            self.marker_cache = self._filter_marker_cache(self.marker_cache_unfiltered)
            for surface in self.surfaces:
                surface.location_cache = None

        elif notification["subject"] == "surface_tracker.heatmap_params_changed":
            for surface in self.surfaces:
                if surface.name == notification["name"]:
                    self._heatmap_update_requests.add(surface)
                    surface.within_surface_heatmap = surface.get_placeholder_heatmap()
                    break
            self._fill_gaze_on_surf_buffer()

        elif notification["subject"].startswith("seek_control.trim_indices_changed"):
            for surface in self.surfaces:
                surface.within_surface_heatmap = surface.get_placeholder_heatmap()
                self._heatmap_update_requests.add(surface)
            self._fill_gaze_on_surf_buffer()

        elif notification["subject"] == "surface_tracker.surfaces_changed":
            for surface in self.surfaces:
                if surface.name == notification["name"]:
                    surface.location_cache = None
                    surface.within_surface_heatmap = surface.get_placeholder_heatmap()
                    self._heatmap_update_requests.add(surface)
                    break

        elif notification["subject"] == "should_export":
            proxy = background_tasks.get_export_proxy(
                notification["export_dir"],
                notification["range"],
                self.surfaces,
                self.g_pool.timestamps,
                self.g_pool.gaze_positions,
                self.camera_model,
                mp_context,
            )
            self.export_proxies.add(proxy)

        elif notification["subject"] == "gaze_positions_changed":
            for surface in self.surfaces:
                self._heatmap_update_requests.add(surface)
                surface.within_surface_heatmap = surface.get_placeholder_heatmap()
            self._fill_gaze_on_surf_buffer()

    def on_surface_change(self, surface):
        self.save_surface_definitions_to_file()
        self._heatmap_update_requests.add(surface)
        self._fill_gaze_on_surf_buffer()

    def deinit_ui(self):
        super().deinit_ui()
        self.g_pool.user_timelines.remove(self.timeline)
        self.timeline = None
        self.glfont = None

    def cleanup(self):
        super().cleanup()
        self._save_marker_cache()

        for proxy in self.export_proxies:
            proxy.cancel()
            self.export_proxies.remove(proxy)

    def _save_marker_cache(self):
        marker_cache_file = file_methods.Persistent_Dict(
            os.path.join(self.g_pool.rec_dir, "square_marker_cache")
        )
        marker_cache_file["marker_cache_unfiltered"] = list(
            self.marker_cache_unfiltered
        )
        marker_cache_file["version"] = self.MARKER_CACHE_VERSION
        marker_cache_file["inverted_markers"] = self.inverted_markers
        marker_cache_file.save()
class Surface_Tracker_Offline(Surface_Tracker, Analysis_Plugin_Base):
    """
    The Surface_Tracker_Offline does marker based AOI tracking in a recording. All
    marker and surface detections are calculated in the background and cached to reduce
    computation.
    """

    order = 0.2
    TIMELINE_LINE_HEIGHT = 16

    def __init__(self, g_pool, marker_min_perimeter=60, inverted_markers=False):
        super().__init__(g_pool, marker_min_perimeter, inverted_markers)

        self.MARKER_CACHE_VERSION = 3
        # Also add very small detected markers to cache and filter cache afterwards
        self.CACHE_MIN_MARKER_PERIMETER = 20
        self.cache_seek_idx = mp_context.Value("i", 0)
        self.marker_cache = None
        self.marker_cache_unfiltered = None
        self.cache_filler = None
        self._init_marker_cache()
        self.last_cache_update_ts = time.time()
        self.CACHE_UPDATE_INTERVAL_SEC = 5

        self.gaze_on_surf_buffer = None
        self.gaze_on_surf_buffer_filler = None

        self._heatmap_update_requests = set()
        self.export_proxies = set()

    @property
    def Surface_Class(self):
        return Surface_Offline

    @property
    def _save_dir(self):
        return self.g_pool.rec_dir

    @property
    def has_freeze_feature(self):
        return False

    @property
    def ui_info_text(self):
        return (
            "The offline surface tracker will look for markers in the entire "
            "video. By default it uses surfaces defined in capture. You can "
            "change and add more surfaces here. \n \n Press the export button or "
            "type 'e' to start the export."
        )

    @property
    def supported_heatmap_modes(self):
        return [Heatmap_Mode.WITHIN_SURFACE, Heatmap_Mode.ACROSS_SURFACES]

    def _init_marker_cache(self):
        previous_cache = file_methods.Persistent_Dict(
            os.path.join(self.g_pool.rec_dir, "square_marker_cache")
        )
        version = previous_cache.get("version", 0)
        cache = previous_cache.get("marker_cache_unfiltered", None)

        if cache is None:
            self._recalculate_marker_cache()
        elif version != self.MARKER_CACHE_VERSION:
            logger.debug("Marker cache version missmatch. Rebuilding marker cache.")
            self.inverted_markers = previous_cache.get("inverted_markers", False)
            self._recalculate_marker_cache()
        else:
            marker_cache_unfiltered = []
            for markers in cache:
                # Loaded markers are either False, [] or a list of dictionaries. We
                # need to convert the dictionaries into Square_Marker_Detection objects.
                if markers:

                    markers = [
                        Square_Marker_Detection(*args) if args else None
                        for args in markers
                    ]
                marker_cache_unfiltered.append(markers)

            self._recalculate_marker_cache(previous_state=marker_cache_unfiltered)
            self.inverted_markers = previous_cache.get("inverted_markers", False)
            logger.debug("Restored previous marker cache.")

    def _recalculate_marker_cache(self, previous_state=None):
        if previous_state is None:
            previous_state = [None for _ in self.g_pool.timestamps]

            # If we had a previous_state argument, surface objects had just been
            # initialized with their previous state, which we do not want to overwrite.
            # Therefore resetting the marker cache is only done when no previous_state
            # is defined.
            for surface in self.surfaces:
                surface.location_cache = None

        self.marker_cache_unfiltered = Cache(previous_state)
        self.marker_cache = self._filter_marker_cache(self.marker_cache_unfiltered)

        self.cache_filler = background_tasks.background_video_processor(
            self.g_pool.capture.source_path,
            offline_utils.marker_detection_callable(
                self.CACHE_MIN_MARKER_PERIMETER, self.inverted_markers
            ),
            list(self.marker_cache),
            self.cache_seek_idx,
            mp_context,
        )

    def _filter_marker_cache(self, cache_to_filter):
        marker_cache = []
        for markers in cache_to_filter:
            if markers:
                markers = self._filter_markers(markers)
            marker_cache.append(markers)
        return Cache(marker_cache)

    def init_ui(self):
        super().init_ui()

        self.glfont = pyglui.pyfontstash.fontstash.Context()
        self.glfont.add_font("opensans", pyglui.ui.get_opensans_font_path())
        self.glfont.set_color_float((1.0, 1.0, 1.0, 0.8))
        self.glfont.set_align_string(v_align="right", h_align="top")

        self.timeline = pyglui.ui.Timeline(
            "Surface Tracker",
            self._gl_display_cache_bars,
            self._draw_labels,
            self.TIMELINE_LINE_HEIGHT * (len(self.surfaces) + 1),
        )
        self.g_pool.user_timelines.append(self.timeline)
        self.timeline.content_height = (
            len(self.surfaces) + 1
        ) * self.TIMELINE_LINE_HEIGHT

    def recent_events(self, events):
        super().recent_events(events)
        self._fetch_data_from_bg_fillers()

    def _fetch_data_from_bg_fillers(self):
        if self.gaze_on_surf_buffer_filler is not None:
            for gaze in self.gaze_on_surf_buffer_filler.fetch():
                self.gaze_on_surf_buffer.append(gaze)

            if self.gaze_on_surf_buffer_filler.completed:
                self.gaze_on_surf_buffer_filler = None
                self._update_surface_heatmaps()
                self.gaze_on_surf_buffer = None

        for proxy in list(self.export_proxies):
            for _ in proxy.fetch():
                pass

            if proxy.completed:
                self.export_proxies.remove(proxy)

    def _update_markers(self, frame):
        self._update_marker_and_surface_caches()

        self.markers = self.marker_cache[frame.index]
        self.markers_unfiltered = self.marker_cache_unfiltered[frame.index]
        if self.markers is None:
            # Move seek index to current frame because caches do not contain data for it
            self.markers = []
            self.markers_unfiltered = []
            self.cache_seek_idx.value = frame.index

    def _update_marker_and_surface_caches(self):
        if self.cache_filler is None:
            return

        for frame_index, markers in self.cache_filler.fetch():
            markers = self._remove_duplicate_markers(markers)
            self.marker_cache_unfiltered.update(frame_index, markers)
            markers_filtered = self._filter_markers(markers)
            self.marker_cache.update(frame_index, markers_filtered)

            for surface in self.surfaces:
                surface.update_location_cache(
                    frame_index, self.marker_cache, self.camera_model
                )

        if self.cache_filler.completed:
            self.cache_filler = None
            for surface in self.surfaces:
                self._heatmap_update_requests.add(surface)
            self._fill_gaze_on_surf_buffer()
            self._save_marker_cache()
            self.save_surface_definitions_to_file()

        now = time.time()
        if now - self.last_cache_update_ts > self.CACHE_UPDATE_INTERVAL_SEC:
            self._save_marker_cache()
            self.last_cache_update_ts = now

    def _update_surface_locations(self, frame_index):
        for surface in self.surfaces:
            surface.update_location(frame_index, self.marker_cache, self.camera_model)

    def _update_surface_corners(self):
        for surface, corner_idx in self._edit_surf_verts:
            if surface.detected:
                surface.move_corner(
                    self.current_frame.index,
                    self.marker_cache,
                    corner_idx,
                    self._last_mouse_pos.copy(),
                    self.camera_model,
                )

    def _update_surface_heatmaps(self):
        self._compute_across_surfaces_heatmap()

        for surface in self._heatmap_update_requests:
            surf_idx = self.surfaces.index(surface)
            gaze_on_surf = self.gaze_on_surf_buffer[surf_idx]
            gaze_on_surf = list(itertools.chain.from_iterable(gaze_on_surf))
            surface.update_heatmap(gaze_on_surf)

        self._heatmap_update_requests.clear()

    def _compute_across_surfaces_heatmap(self):
        gaze_counts_per_surf = []
        for gaze in self.gaze_on_surf_buffer:
            gaze = list(itertools.chain.from_iterable(gaze))
            gaze = [g for g in gaze if g["on_surf"]]
            gaze_counts_per_surf.append(len(gaze))

        if gaze_counts_per_surf:
            max_count = max(gaze_counts_per_surf)
            results = np.array(gaze_counts_per_surf, dtype=np.float32)
            if max_count > 0:
                results *= 255.0 / max_count
            results = np.uint8(results)
            results_color_maps = cv2.applyColorMap(results, cv2.COLORMAP_JET)

            for surface, color_map in zip(self.surfaces, results_color_maps):
                heatmap = np.ones((1, 1, 4), dtype=np.uint8) * 125
                heatmap[:, :, :3] = color_map
                surface.across_surface_heatmap = heatmap
        else:
            for surface in self.surfaces:
                surface.across_surface_heatmap = surface.get_uniform_heatmap()

    def _fill_gaze_on_surf_buffer(self):
        in_mark = self.g_pool.seek_control.trim_left
        out_mark = self.g_pool.seek_control.trim_right
        section = slice(in_mark, out_mark)

        all_world_timestamps = self.g_pool.timestamps
        all_gaze_events = self.g_pool.gaze_positions

        self._start_gaze_buffer_filler(all_gaze_events, all_world_timestamps, section)

    def _start_gaze_buffer_filler(self, all_gaze_events, all_world_timestamps, section):
        if self.gaze_on_surf_buffer_filler is not None:
            self.gaze_on_surf_buffer_filler.cancel()
        self.gaze_on_surf_buffer = []
        self.gaze_on_surf_buffer_filler = background_tasks.background_gaze_on_surface(
            self.surfaces,
            section,
            all_world_timestamps,
            all_gaze_events,
            self.camera_model,
            mp_context,
        )

    def _start_fixation_buffer_filler(
        self, all_fixation_events, all_world_timestamps, section
    ):
        if self.fixations_on_surf_buffer_filler is not None:
            self.fixations_on_surf_buffer_filler.cancel()
        self.fixations_on_surf_buffer = []
        self.fixations_on_surf_buffer_filler = background_tasks.background_gaze_on_surface(
            self.surfaces,
            section,
            all_world_timestamps,
            all_fixation_events,
            self.camera_model,
            mp_context,
        )

    def gl_display(self):
        if self.timeline:
            self.timeline.refresh()
        super().gl_display()

    def _gl_display_cache_bars(self, width, height, scale):
        ts = self.g_pool.timestamps
        with gl_utils.Coord_System(ts[0], ts[-1], height, 0):
            # Lines for areas that have been cached
            cached_ranges = []
            for r in self.marker_cache.visited_ranges:
                cached_ranges += ((ts[r[0]], 0), (ts[r[1]], 0))

            gl.glTranslatef(0, scale * self.TIMELINE_LINE_HEIGHT / 2, 0)
            color = pyglui_utils.RGBA(0.8, 0.2, 0.2, 0.8)
            pyglui_utils.draw_polyline(
                cached_ranges, color=color, line_type=gl.GL_LINES, thickness=scale * 4
            )
            cached_ranges = []
            for r in self.marker_cache.positive_ranges:
                cached_ranges += ((ts[r[0]], 0), (ts[r[1]], 0))

            color = pyglui_utils.RGBA(0, 0.7, 0.3, 0.8)
            pyglui_utils.draw_polyline(
                cached_ranges, color=color, line_type=gl.GL_LINES, thickness=scale * 4
            )

            # Lines where surfaces have been found in video
            cached_surfaces = []
            for surface in self.surfaces:
                found_at = []
                if surface.location_cache is not None:
                    for r in surface.location_cache.positive_ranges:  # [[0,1],[3,4]]
                        found_at += ((ts[r[0]], 0), (ts[r[1]], 0))
                    cached_surfaces.append(found_at)

            color = pyglui_utils.RGBA(0, 0.7, 0.3, 0.8)

            for surface in cached_surfaces:
                gl.glTranslatef(0, scale * self.TIMELINE_LINE_HEIGHT, 0)
                pyglui_utils.draw_polyline(
                    surface, color=color, line_type=gl.GL_LINES, thickness=scale * 2
                )

    def _draw_labels(self, width, height, scale):
        self.glfont.set_size(self.TIMELINE_LINE_HEIGHT * 0.8 * scale)
        self.glfont.draw_text(width, 0, "Marker Cache")
        for surface in self.surfaces:
            gl.glTranslatef(0, self.TIMELINE_LINE_HEIGHT * scale, 0)
            self.glfont.draw_text(width, 0, surface.name)

    def add_surface(self, init_dict=None):
        super().add_surface(init_dict)

        try:
            self.timeline.content_height += self.TIMELINE_LINE_HEIGHT
            self._fill_gaze_on_surf_buffer()
        except AttributeError:
            pass
        self.surfaces[-1].on_surface_change = self.on_surface_change

    def remove_surface(self, surface):
        super().remove_surface(surface)
        try:
            self._heatmap_update_requests.remove(surface)
        except KeyError:
            pass
        self.timeline.content_height -= self.TIMELINE_LINE_HEIGHT

    def on_notify(self, notification):
        super().on_notify(notification)

        if notification["subject"] == "surface_tracker.marker_detection_params_changed":
            self._recalculate_marker_cache()

        elif notification["subject"] == "surface_tracker.marker_min_perimeter_changed":
            self.marker_cache = self._filter_marker_cache(self.marker_cache_unfiltered)
            for surface in self.surfaces:
                surface.location_cache = None

        elif notification["subject"] == "surface_tracker.heatmap_params_changed":
            for surface in self.surfaces:
                if surface.name == notification["name"]:
                    self._heatmap_update_requests.add(surface)
                    surface.within_surface_heatmap = surface.get_placeholder_heatmap()
                    break
            self._fill_gaze_on_surf_buffer()

        elif notification["subject"].startswith("seek_control.trim_indices_changed"):
            for surface in self.surfaces:
                surface.within_surface_heatmap = surface.get_placeholder_heatmap()
                self._heatmap_update_requests.add(surface)
            self._fill_gaze_on_surf_buffer()

        elif notification["subject"] == "surface_tracker.surfaces_changed":
            for surface in self.surfaces:
                if surface.name == notification["name"]:
                    surface.location_cache = None
                    surface.within_surface_heatmap = surface.get_placeholder_heatmap()
                    self._heatmap_update_requests.add(surface)
                    break

        elif notification["subject"] == "should_export":
            proxy = background_tasks.get_export_proxy(
                notification["export_dir"],
                notification["range"],
                self.surfaces,
                self.g_pool.timestamps,
                self.g_pool.gaze_positions,
                self.g_pool.fixations,
                self.camera_model,
                mp_context,
            )
            self.export_proxies.add(proxy)

        elif notification["subject"] == "gaze_positions_changed":
            for surface in self.surfaces:
                self._heatmap_update_requests.add(surface)
                surface.within_surface_heatmap = surface.get_placeholder_heatmap()
            self._fill_gaze_on_surf_buffer()

    def on_surface_change(self, surface):
        self.save_surface_definitions_to_file()
        self._heatmap_update_requests.add(surface)
        self._fill_gaze_on_surf_buffer()

    def deinit_ui(self):
        super().deinit_ui()
        self.g_pool.user_timelines.remove(self.timeline)
        self.timeline = None
        self.glfont = None

    def cleanup(self):
        super().cleanup()
        self._save_marker_cache()

        for proxy in self.export_proxies:
            proxy.cancel()
            self.export_proxies.remove(proxy)

    def _save_marker_cache(self):
        marker_cache_file = file_methods.Persistent_Dict(
            os.path.join(self.g_pool.rec_dir, "square_marker_cache")
        )
        marker_cache_file["marker_cache_unfiltered"] = list(
            self.marker_cache_unfiltered
        )
        marker_cache_file["version"] = self.MARKER_CACHE_VERSION
        marker_cache_file["inverted_markers"] = self.inverted_markers
        marker_cache_file.save()