#!/usr/bin/env python3 """ Handles Navigation and Background Image for the Frame Viewer section of the manual tool GUI. """ import logging import tkinter as tk import cv2 import numpy as np from PIL import Image, ImageTk from lib.align import AlignedFace logger = logging.getLogger(__name__) class Navigation(): """ Handles playback and frame navigation for the Frame Viewer Window. Parameters ---------- display_frame: :class:`DisplayFrame` The parent frame viewer window """ def __init__(self, display_frame): logger.debug("Initializing %s", self.__class__.__name__) self._display_frame = display_frame self._globals = display_frame._globals self._det_faces = display_frame._det_faces self._nav = display_frame._nav self._tk_is_playing = tk.BooleanVar() self._tk_is_playing.set(False) self._det_faces.tk_face_count_changed.trace("w", self._update_total_frame_count) logger.debug("Initialized %s", self.__class__.__name__) @property def _current_nav_frame_count(self): """ int: The current frame count for the transport slider """ return self._nav["scale"].cget("to") + 1 def nav_scale_callback(self, *args, reset_progress=True): # pylint:disable=unused-argument """ Adjust transport slider scale for different filters. Hide or display optional filter controls. """ self._display_frame.pack_threshold_slider() if reset_progress: self.stop_playback() frame_count = self._det_faces.filter.count if self._current_nav_frame_count == frame_count: logger.trace("Filtered count has not changed. Returning") if self._globals.tk_filter_mode.get() == "Misaligned Faces": self._det_faces.tk_face_count_changed.set(True) self._update_total_frame_count() if reset_progress: self._globals.tk_transport_index.set(0) def _update_total_frame_count(self, *args): # pylint:disable=unused-argument """ Update the displayed number of total frames that meet the current filter criteria. Parameters ---------- args: tuple Required for tkinter trace callback but unused """ frame_count = self._det_faces.filter.count if self._current_nav_frame_count == frame_count: logger.trace("Filtered count has not changed. Returning") return max_frame = max(0, frame_count - 1) logger.debug("Filtered frame count has changed. Updating from %s to %s", self._current_nav_frame_count, frame_count) self._nav["scale"].config(to=max_frame) self._nav["label"].config(text="/{}".format(max_frame)) state = "disabled" if max_frame == 0 else "normal" self._nav["entry"].config(state=state) @property def tk_is_playing(self): """ :class:`tkinter.BooleanVar`: Whether the stream is currently playing. """ return self._tk_is_playing def handle_play_button(self): """ Handle the play button. Switches the :attr:`tk_is_playing` variable. """ is_playing = self.tk_is_playing.get() self.tk_is_playing.set(not is_playing) def stop_playback(self): """ Stop play back if playing """ if self.tk_is_playing.get(): logger.trace("Stopping playback") self.tk_is_playing.set(False) def increment_frame(self, frame_count=None, is_playing=False): """ Update The frame navigation position to the next frame based on filter. """ if not is_playing: self.stop_playback() position = self._get_safe_frame_index() face_count_change = not self._det_faces.filter.frame_meets_criteria if face_count_change: position -= 1 frame_count = self._det_faces.filter.count if frame_count is None else frame_count if not face_count_change and (frame_count == 0 or position == frame_count - 1): logger.debug("End of Stream. Not incrementing") self.stop_playback() return self._globals.tk_transport_index.set(min(position + 1, max(0, frame_count - 1))) def decrement_frame(self): """ Update The frame navigation position to the previous frame based on filter. """ self.stop_playback() position = self._get_safe_frame_index() face_count_change = not self._det_faces.filter.frame_meets_criteria if not face_count_change and (self._det_faces.filter.count == 0 or position == 0): logger.debug("End of Stream. Not decrementing") return self._globals.tk_transport_index.set(min(max(0, self._det_faces.filter.count - 1), max(0, position - 1))) def _get_safe_frame_index(self): """ Obtain the current frame position from the tk_transport_index variable in a safe manner (i.e. handle for non-numeric) Returns ------- int The current transport frame index """ try: retval = self._globals.tk_transport_index.get() except tk.TclError as err: if "expected floating-point" not in str(err): raise val = str(err).split(" ")[-1].replace("\"", "") retval = "".join(ch for ch in val if ch.isdigit()) retval = 0 if not retval else int(retval) self._globals.tk_transport_index.set(retval) return retval def goto_first_frame(self): """ Go to the first frame that meets the filter criteria. """ self.stop_playback() position = self._globals.tk_transport_index.get() if position == 0: return self._globals.tk_transport_index.set(0) def goto_last_frame(self): """ Go to the last frame that meets the filter criteria. """ self.stop_playback() position = self._globals.tk_transport_index.get() frame_count = self._det_faces.filter.count if position == frame_count - 1: return self._globals.tk_transport_index.set(frame_count - 1) class BackgroundImage(): # pylint:disable=too-few-public-methods """ The background image of the canvas """ def __init__(self, canvas): self._canvas = canvas self._globals = canvas._globals self._det_faces = canvas._det_faces placeholder = np.ones((*reversed(self._globals.frame_display_dims), 3), dtype="uint8") self._tk_frame = ImageTk.PhotoImage(Image.fromarray(placeholder)) self._tk_face = ImageTk.PhotoImage(Image.fromarray(placeholder)) self._image = self._canvas.create_image(self._globals.frame_display_dims[0] / 2, self._globals.frame_display_dims[1] / 2, image=self._tk_frame, anchor=tk.CENTER, tags="main_image") self._zoomed_centering = "face" @property def _current_view_mode(self): """ str: `frame` if global zoom mode variable is set to ``False`` other wise `face`. """ retval = "face" if self._globals.is_zoomed else "frame" logger.trace(retval) return retval def refresh(self, view_mode): """ Update the displayed frame. Parameters ---------- view_mode: ["frame", "face"] The currently active editor's selected view mode. """ self._switch_image(view_mode) logger.trace("Updating background frame") getattr(self, "_update_tk_{}".format(self._current_view_mode))() def _switch_image(self, view_mode): """ Switch the image between the full frame image and the zoomed face image. Parameters ---------- view_mode: ["frame", "face"] The currently active editor's selected view mode. """ if view_mode == self._current_view_mode and ( self._canvas.active_editor.zoomed_centering == self._zoomed_centering): return self._zoomed_centering = self._canvas.active_editor.zoomed_centering logger.trace("Switching background image from '%s' to '%s'", self._current_view_mode, view_mode) img = getattr(self, "_tk_{}".format(view_mode)) self._canvas.itemconfig(self._image, image=img) self._globals.tk_is_zoomed.set(view_mode == "face") self._globals.tk_face_index.set(0) def _update_tk_face(self): """ Update the currently zoomed face. """ face = self._get_zoomed_face() padding = self._get_padding((min(self._globals.frame_display_dims), min(self._globals.frame_display_dims))) face = cv2.copyMakeBorder(face, *padding, cv2.BORDER_CONSTANT) if self._tk_frame.height() != face.shape[0]: self._resize_frame() logger.trace("final shape: %s", face.shape) self._tk_face.paste(Image.fromarray(face)) def _get_zoomed_face(self): """ Get the zoomed face or a blank image if no faces are available. Returns ------- :class:`numpy.ndarray` The face sized to the shortest dimensions of the face viewer """ frame_idx = self._globals.frame_index face_idx = self._globals.face_index faces_in_frame = self._det_faces.face_count_per_index[frame_idx] size = min(self._globals.frame_display_dims) if face_idx + 1 > faces_in_frame: logger.debug("Resetting face index to 0 for more faces in frame than current index: (" "faces_in_frame: %s, zoomed_face_index: %s", faces_in_frame, face_idx) self._globals.tk_face_index.set(0) if faces_in_frame == 0: face = np.ones((size, size, 3), dtype="uint8") else: det_face = self._det_faces.current_faces[frame_idx][face_idx] face = AlignedFace(det_face.landmarks_xy, image=self._globals.current_frame["image"], centering=self._zoomed_centering, size=size).face logger.trace("face shape: %s", face.shape) return face[..., 2::-1] def _update_tk_frame(self): """ Place the currently held frame into :attr:`_tk_frame`. """ img = cv2.resize(self._globals.current_frame["image"], self._globals.current_frame["display_dims"], interpolation=self._globals.current_frame["interpolation"])[..., 2::-1] padding = self._get_padding(img.shape[:2]) if any(padding): img = cv2.copyMakeBorder(img, *padding, cv2.BORDER_CONSTANT) logger.trace("final shape: %s", img.shape) if self._tk_frame.height() != img.shape[0]: self._resize_frame() self._tk_frame.paste(Image.fromarray(img)) def _get_padding(self, size): """ Obtain the Left, Top, Right, Bottom padding required to place the square face or frame in to the Photo Image Returns ------- tuple The (Left, Top, Right, Bottom) padding to apply to the face image in pixels """ pad_lt = ((self._globals.frame_display_dims[1] - size[0]) // 2, (self._globals.frame_display_dims[0] - size[1]) // 2) padding = (pad_lt[0], self._globals.frame_display_dims[1] - size[0] - pad_lt[0], pad_lt[1], self._globals.frame_display_dims[0] - size[1] - pad_lt[1]) logger.debug("Frame dimensions: %s, size: %s, padding: %s", self._globals.frame_display_dims, size, padding) return padding def _resize_frame(self): """ Resize the :attr:`_tk_frame`, attr:`_tk_face` photo images, update the canvas to offset the image correctly. """ logger.trace("Resizing video frame on resize event: %s", self._globals.frame_display_dims) placeholder = np.ones((*reversed(self._globals.frame_display_dims), 3), dtype="uint8") self._tk_frame = ImageTk.PhotoImage(Image.fromarray(placeholder)) self._tk_face = ImageTk.PhotoImage(Image.fromarray(placeholder)) self._canvas.coords(self._image, self._globals.frame_display_dims[0] / 2, self._globals.frame_display_dims[1] / 2) img = self._tk_face if self._current_view_mode == "face" else self._tk_frame self._canvas.itemconfig(self._image, image=img)