mirror of
https://github.com/deepfakes/faceswap
synced 2025-06-07 10:43:27 -04:00
753 lines
31 KiB
Python
753 lines
31 KiB
Python
#!/usr/bin/env python3
|
|
""" The Faces Viewer Frame and Canvas for Faceswap's Manual Tool. """
|
|
import colorsys
|
|
import gettext
|
|
import logging
|
|
import platform
|
|
import tkinter as tk
|
|
from tkinter import ttk
|
|
from math import floor, ceil
|
|
from threading import Thread, Event
|
|
|
|
import numpy as np
|
|
|
|
from lib.gui.custom_widgets import RightClickMenu, Tooltip
|
|
from lib.gui.utils import get_config, get_images
|
|
from lib.image import hex_to_rgb, rgb_to_hex
|
|
|
|
from .viewport import Viewport
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# LOCALES
|
|
_LANG = gettext.translation("tools.manual", localedir="locales", fallback=True)
|
|
_ = _LANG.gettext
|
|
|
|
|
|
class FacesFrame(ttk.Frame): # pylint:disable=too-many-ancestors
|
|
""" The faces display frame (bottom section of GUI). This frame holds the faces viewport and
|
|
the tkinter objects.
|
|
|
|
Parameters
|
|
----------
|
|
parent: :class:`ttk.PanedWindow`
|
|
The paned window that the faces frame resides in
|
|
tk_globals: :class:`~tools.manual.manual.TkGlobals`
|
|
The tkinter variables that apply to the whole of the GUI
|
|
detected_faces: :class:`~tools.manual.detected_faces.DetectedFaces`
|
|
The :class:`~lib.align.DetectedFace` objects for this video
|
|
display_frame: :class:`~tools.manual.frameviewer.frame.DisplayFrame`
|
|
The section of the Manual Tool that holds the frames viewer
|
|
"""
|
|
def __init__(self, parent, tk_globals, detected_faces, display_frame):
|
|
logger.debug("Initializing %s: (parent: %s, tk_globals: %s, detected_faces: %s, "
|
|
"display_frame: %s)", self.__class__.__name__, parent, tk_globals,
|
|
detected_faces, display_frame)
|
|
super().__init__(parent)
|
|
self.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
|
|
self._actions_frame = FacesActionsFrame(self)
|
|
|
|
self._faces_frame = ttk.Frame(self)
|
|
self._faces_frame.pack_propagate(0)
|
|
self._faces_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
self._event = Event()
|
|
self._canvas = FacesViewer(self._faces_frame,
|
|
tk_globals,
|
|
self._actions_frame._tk_vars,
|
|
detected_faces,
|
|
display_frame,
|
|
self._event)
|
|
self._add_scrollbar()
|
|
logger.debug("Initialized %s", self.__class__.__name__)
|
|
|
|
def _add_scrollbar(self):
|
|
""" Add a scrollbar to the faces frame """
|
|
logger.debug("Add Faces Viewer Scrollbar")
|
|
scrollbar = ttk.Scrollbar(self._faces_frame, command=self._on_scroll)
|
|
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
|
self._canvas.config(yscrollcommand=scrollbar.set)
|
|
self.bind("<Configure>", self._update_viewport)
|
|
logger.debug("Added Faces Viewer Scrollbar")
|
|
self.update_idletasks() # Update so scrollbar width is correct
|
|
return scrollbar.winfo_width()
|
|
|
|
def _on_scroll(self, *event):
|
|
""" Callback on scrollbar scroll. Updates the canvas location and displays/hides
|
|
thumbnail images.
|
|
|
|
Parameters
|
|
----------
|
|
event :class:`tkinter.Event`
|
|
The scrollbar callback event
|
|
"""
|
|
self._canvas.yview(*event)
|
|
self._canvas.viewport.update()
|
|
|
|
def _update_viewport(self, event): # pylint: disable=unused-argument
|
|
""" Update the faces viewport and scrollbar.
|
|
|
|
Parameters
|
|
----------
|
|
event: :class:`tkinter.Event`
|
|
Unused but required
|
|
"""
|
|
self._canvas.viewport.update()
|
|
self._canvas.configure(scrollregion=self._canvas.bbox("backdrop"))
|
|
|
|
def canvas_scroll(self, direction):
|
|
""" Scroll the canvas on an up/down or page-up/page-down key press.
|
|
|
|
Notes
|
|
-----
|
|
To protect against a held down key press stacking tasks and locking up the GUI
|
|
a background thread is launched and discards subsequent key presses whilst the
|
|
previous update occurs.
|
|
|
|
Parameters
|
|
----------
|
|
direction: ["up", "down", "page-up", "page-down"]
|
|
The request page scroll direction and amount.
|
|
"""
|
|
|
|
if self._event.is_set():
|
|
logger.trace("Update already running. Aborting repeated keypress")
|
|
return
|
|
logger.trace("Running update on received key press: %s", direction)
|
|
|
|
amount = 1 if direction.endswith("down") else -1
|
|
units = "pages" if direction.startswith("page") else "units"
|
|
self._event.set()
|
|
thread = Thread(target=self._canvas.canvas_scroll,
|
|
args=(amount, units, self._event))
|
|
thread.start()
|
|
|
|
def set_annotation_display(self, key):
|
|
""" Set the optional annotation overlay based on keyboard shortcut.
|
|
|
|
Parameters
|
|
----------
|
|
key: str
|
|
The pressed key
|
|
"""
|
|
self._actions_frame.on_click(self._actions_frame.key_bindings[key])
|
|
|
|
|
|
class FacesActionsFrame(ttk.Frame): # pylint:disable=too-many-ancestors
|
|
""" The left hand action frame holding the optional annotation buttons.
|
|
|
|
Parameters
|
|
----------
|
|
parent: :class:`FacesFrame`
|
|
The Faces frame that this actions frame reside in
|
|
"""
|
|
def __init__(self, parent):
|
|
logger.debug("Initializing %s: (parent: %s)",
|
|
self.__class__.__name__, parent)
|
|
super().__init__(parent)
|
|
self.pack(side=tk.LEFT, fill=tk.Y, padx=(2, 4), pady=2)
|
|
self._tk_vars = dict()
|
|
self._configure_styles()
|
|
self._buttons = self._add_buttons()
|
|
logger.debug("Initialized %s", self.__class__.__name__)
|
|
|
|
@property
|
|
def key_bindings(self):
|
|
""" dict: The mapping of key presses to optional annotations to display. Keyboard shortcuts
|
|
utilize the function keys. """
|
|
return {"F{}".format(idx + 9): display for idx, display in enumerate(("mesh", "mask"))}
|
|
|
|
@property
|
|
def _helptext(self):
|
|
""" dict: `button key`: `button helptext`. The help text to display for each button. """
|
|
inverse_keybindings = {val: key for key, val in self.key_bindings.items()}
|
|
retval = dict(mesh=_("Display the landmarks mesh"),
|
|
mask=_("Display the mask"))
|
|
for item in retval:
|
|
retval[item] += " ({})".format(inverse_keybindings[item])
|
|
return retval
|
|
|
|
def _configure_styles(self):
|
|
""" Configure the background color for button frame and the button styles. """
|
|
style = ttk.Style()
|
|
style.configure("display.TFrame", background='#d3d3d3')
|
|
style.configure("display_selected.TButton", relief="flat", background="#bedaf1")
|
|
style.configure("display_deselected.TButton", relief="flat")
|
|
self.config(style="display.TFrame")
|
|
|
|
def _add_buttons(self):
|
|
""" Add the display buttons to the Faces window.
|
|
|
|
Returns
|
|
-------
|
|
dict
|
|
The display name and its associated button.
|
|
"""
|
|
frame = ttk.Frame(self)
|
|
frame.pack(side=tk.TOP, fill=tk.Y)
|
|
buttons = dict()
|
|
for display in self.key_bindings.values():
|
|
var = tk.BooleanVar()
|
|
var.set(False)
|
|
self._tk_vars[display] = var
|
|
|
|
lookup = "landmarks" if display == "mesh" else display
|
|
button = ttk.Button(frame,
|
|
image=get_images().icons[lookup],
|
|
command=lambda t=display: self.on_click(t),
|
|
style="display_deselected.TButton")
|
|
button.state(["!pressed", "!focus"])
|
|
button.pack()
|
|
Tooltip(button, text=self._helptext[display])
|
|
buttons[display] = button
|
|
return buttons
|
|
|
|
def on_click(self, display):
|
|
""" Click event for the optional annotation buttons. Loads and unloads the annotations from
|
|
the faces viewer.
|
|
|
|
Parameters
|
|
----------
|
|
display: str
|
|
The display name for the button that has called this event as exists in
|
|
:attr:`_buttons`
|
|
"""
|
|
is_pressed = not self._tk_vars[display].get()
|
|
style = "display_selected.TButton" if is_pressed else "display_deselected.TButton"
|
|
state = ["pressed", "focus"] if is_pressed else ["!pressed", "!focus"]
|
|
btn = self._buttons[display]
|
|
btn.configure(style=style)
|
|
btn.state(state)
|
|
self._tk_vars[display].set(is_pressed)
|
|
|
|
|
|
class FacesViewer(tk.Canvas): # pylint:disable=too-many-ancestors
|
|
""" The :class:`tkinter.Canvas` that holds the faces viewer section of the Manual Tool.
|
|
|
|
Parameters
|
|
----------
|
|
parent: :class:`tkinter.ttk.Frame`
|
|
The parent frame for the canvas
|
|
tk_globals: :class:`~tools.manual.manual.TkGlobals`
|
|
The tkinter variables that apply to the whole of the GUI
|
|
tk_action_vars: dict
|
|
The :class:`tkinter.BooleanVar` objects for selectable optional annotations
|
|
as set by the buttons in the :class:`FacesActionsFrame`
|
|
detected_faces: :class:`~tools.manual.detected_faces.DetectedFaces`
|
|
The :class:`~lib.align.DetectedFace` objects for this video
|
|
display_frame: :class:`~tools.manual.frameviewer.frame.DisplayFrame`
|
|
The section of the Manual Tool that holds the frames viewer
|
|
event: :class:`threading.Event`
|
|
The threading event object for repeated key press protection
|
|
"""
|
|
def __init__(self, parent, tk_globals, tk_action_vars, detected_faces, display_frame, event):
|
|
logger.debug("Initializing %s: (parent: %s, tk_globals: %s, tk_action_vars: %s, "
|
|
"detected_faces: %s, display_frame: %s, event: %s)", self.__class__.__name__,
|
|
parent, tk_globals, tk_action_vars, detected_faces, display_frame, event)
|
|
super().__init__(parent,
|
|
bd=0,
|
|
highlightthickness=0,
|
|
bg=get_config().user_theme["group_panel"]["panel_background"])
|
|
self.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, anchor=tk.E)
|
|
self._sizes = dict(tiny=32, small=64, medium=96, large=128, extralarge=192)
|
|
|
|
self._globals = tk_globals
|
|
self._tk_optional_annotations = tk_action_vars
|
|
self._event = event
|
|
self._display_frame = display_frame
|
|
self._grid = Grid(self, detected_faces)
|
|
self._view = Viewport(self, detected_faces.tk_edited)
|
|
self._annotation_colors = dict(mesh=self.get_muted_color("Mesh"),
|
|
box=self.control_colors["ExtractBox"])
|
|
|
|
ContextMenu(self, detected_faces)
|
|
self._bind_mouse_wheel_scrolling()
|
|
self._set_tk_callbacks(detected_faces)
|
|
logger.debug("Initialized %s", self.__class__.__name__)
|
|
|
|
@property
|
|
def face_size(self):
|
|
""" int: The currently selected thumbnail size in pixels """
|
|
scaling = get_config().scaling_factor
|
|
size = self._sizes[self._globals.tk_faces_size.get().lower().replace(" ", "")]
|
|
scaled = size * scaling
|
|
return int(round(scaled / 2) * 2)
|
|
|
|
@property
|
|
def viewport(self):
|
|
""" :class:`~tools.manual.faceviewer.viewport.Viewport`: The viewport area of the
|
|
faces viewer. """
|
|
return self._view
|
|
|
|
@property
|
|
def grid(self):
|
|
""" :class:`Grid`: The grid for the current :class:`FacesViewer`. """
|
|
return self._grid
|
|
|
|
@property
|
|
def optional_annotations(self):
|
|
""" dict: The values currently set for the selectable optional annotations. """
|
|
return {opt: val.get() for opt, val in self._tk_optional_annotations.items()}
|
|
|
|
@property
|
|
def selected_mask(self):
|
|
""" str: The currently selected mask from the display frame control panel. """
|
|
return self._display_frame.tk_selected_mask.get().lower()
|
|
|
|
@property
|
|
def control_colors(self):
|
|
""" :dict: The frame Editor name as key with the current user selected hex code as
|
|
value. """
|
|
return ({key: val.get() for key, val in self._display_frame.tk_control_colors.items()})
|
|
|
|
# << CALLBACK FUNCTIONS >> #
|
|
def _set_tk_callbacks(self, detected_faces):
|
|
""" Set the tkinter variable call backs.
|
|
|
|
Redraw the grid on a face size change, a filter change or on add/remove faces.
|
|
Updates the annotation colors when user amends a color drop down.
|
|
Updates the mask type when the user changes the selected mask types
|
|
Toggles the face viewer annotations on an optional annotation button press.
|
|
"""
|
|
for var in (self._globals.tk_faces_size, self._globals.tk_filter_mode):
|
|
var.trace("w", lambda *e, v=var: self.refresh_grid(v))
|
|
var = detected_faces.tk_face_count_changed
|
|
var.trace("w", lambda *e, v=var: self.refresh_grid(v, retain_position=True))
|
|
|
|
self._display_frame.tk_control_colors["Mesh"].trace(
|
|
"w", lambda *e: self._update_mesh_color())
|
|
self._display_frame.tk_control_colors["ExtractBox"].trace(
|
|
"w", lambda *e: self._update_box_color())
|
|
self._display_frame.tk_selected_mask.trace("w", lambda *e: self._update_mask_type())
|
|
|
|
for opt, var in self._tk_optional_annotations.items():
|
|
var.trace("w", lambda *e, o=opt: self._toggle_annotations(o))
|
|
|
|
self.bind("<Configure>", lambda *e: self._view.update())
|
|
|
|
def refresh_grid(self, trigger_var, retain_position=False):
|
|
""" Recalculate the full grid and redraw. Used when the active filter pull down is used, a
|
|
face has been added or removed, or the face thumbnail size has changed.
|
|
|
|
Parameters
|
|
----------
|
|
trigger_var: :class:`tkinter.BooleanVar`
|
|
The tkinter variable that has triggered the grid update. Will either be the variable
|
|
indicating that the face size have been changed, or the variable indicating that the
|
|
selected filter mode has been changed.
|
|
retain_position: bool, optional
|
|
``True`` if the grid should be set back to the position it was at after the update has
|
|
been processed, otherwise ``False``. Default: ``False``.
|
|
"""
|
|
if not trigger_var.get():
|
|
return
|
|
size_change = isinstance(trigger_var, tk.StringVar)
|
|
move_to = self.yview()[0] if retain_position else 0.0
|
|
self._grid.update()
|
|
if move_to != 0.0:
|
|
self.yview_moveto(move_to)
|
|
if size_change:
|
|
self._view.reset()
|
|
self._view.update(refresh_annotations=retain_position)
|
|
if not size_change:
|
|
trigger_var.set(False)
|
|
|
|
def _update_mask_type(self):
|
|
""" Update the displayed mask in the :class:`FacesViewer` canvas when the user changes
|
|
the mask type. """
|
|
state = "normal" if self.optional_annotations["mask"] else "hidden"
|
|
logger.debug("Updating mask type: (mask_type: %s. state: %s)", self.selected_mask, state)
|
|
self._view.toggle_mask(state, self.selected_mask)
|
|
|
|
# << MOUSE HANDLING >>
|
|
def _bind_mouse_wheel_scrolling(self):
|
|
""" Bind mouse wheel to scroll the :class:`FacesViewer` canvas. """
|
|
if platform.system() == "Linux":
|
|
self.bind("<Button-4>", self._scroll)
|
|
self.bind("<Button-5>", self._scroll)
|
|
else:
|
|
self.bind("<MouseWheel>", self._scroll)
|
|
|
|
def _scroll(self, event):
|
|
""" Handle mouse wheel scrolling over the :class:`FacesViewer` canvas.
|
|
|
|
Update is run in a thread to avoid repeated scroll actions stacking and locking up the GUI.
|
|
|
|
Parameters
|
|
----------
|
|
event: :class:`tkinter.Event`
|
|
The event fired by the mouse scrolling
|
|
"""
|
|
if self._event.is_set():
|
|
logger.trace("Update already running. Aborting repeated mousewheel")
|
|
return
|
|
if platform.system() == "Darwin":
|
|
adjust = event.delta
|
|
elif platform.system() == "Windows":
|
|
adjust = event.delta / 120
|
|
elif event.num == 5:
|
|
adjust = -1
|
|
else:
|
|
adjust = 1
|
|
self._event.set()
|
|
thread = Thread(target=self.canvas_scroll, args=(-1 * adjust, "units", self._event))
|
|
thread.start()
|
|
|
|
def canvas_scroll(self, amount, units, event):
|
|
""" Scroll the canvas on an up/down or page-up/page-down key press.
|
|
|
|
Parameters
|
|
----------
|
|
amount: int
|
|
The number of units to scroll the canvas
|
|
units: ["page", "units"]
|
|
The unit type to scroll by
|
|
event: :class:`threading.Event`
|
|
event to indicate to the calling process whether the scroll is still updating
|
|
"""
|
|
self.yview_scroll(int(amount), units)
|
|
self._view.update()
|
|
self._view.hover_box.on_hover(None)
|
|
event.clear()
|
|
|
|
# << OPTIONAL ANNOTATION METHODS >> #
|
|
def _update_mesh_color(self):
|
|
""" Update the mesh color when user updates the control panel. """
|
|
color = self.get_muted_color("Mesh")
|
|
if self._annotation_colors["mesh"] == color:
|
|
return
|
|
highlight_color = self.control_colors["Mesh"]
|
|
|
|
self.itemconfig("viewport_polygon", outline=color)
|
|
self.itemconfig("viewport_line", fill=color)
|
|
self.itemconfig("active_mesh_polygon", outline=highlight_color)
|
|
self.itemconfig("active_mesh_line", fill=highlight_color)
|
|
self._annotation_colors["mesh"] = color
|
|
|
|
def _update_box_color(self):
|
|
""" Update the active box color when user updates the control panel. """
|
|
color = self.control_colors["ExtractBox"]
|
|
|
|
if self._annotation_colors["box"] == color:
|
|
return
|
|
self.itemconfig("active_highlighter", outline=color)
|
|
self._annotation_colors["box"] = color
|
|
|
|
def get_muted_color(self, color_key):
|
|
""" Creates a muted version of the given annotation color for non-active faces.
|
|
|
|
Parameters
|
|
----------
|
|
color_key: str
|
|
The annotation key to obtain the color for from :attr:`control_colors`
|
|
"""
|
|
scale = 0.65
|
|
hls = np.array(colorsys.rgb_to_hls(*hex_to_rgb(self.control_colors[color_key])))
|
|
scale = (1 - scale) + 1 if hls[1] < 120 else scale
|
|
hls[1] = max(0., min(256., scale * hls[1]))
|
|
rgb = np.clip(np.rint(colorsys.hls_to_rgb(*hls)).astype("uint8"), 0, 255)
|
|
retval = rgb_to_hex(rgb)
|
|
return retval
|
|
|
|
def _toggle_annotations(self, annotation):
|
|
""" Toggle optional annotations on or off after the user depresses an optional button.
|
|
|
|
Parameters
|
|
----------
|
|
annotation: ["mesh", "mask"]
|
|
The optional annotation to toggle on or off
|
|
"""
|
|
state = "normal" if self.optional_annotations[annotation] else "hidden"
|
|
logger.debug("Toggle annotation: (annotation: %s, state: %s)", annotation, state)
|
|
if annotation == "mesh":
|
|
self._view.toggle_mesh(state)
|
|
if annotation == "mask":
|
|
self._view.toggle_mask(state, self.selected_mask)
|
|
|
|
|
|
class Grid():
|
|
""" Holds information on the current filtered grid layout.
|
|
|
|
The grid keeps information on frame indices, face indices, x and y positions and detected face
|
|
objects laid out in a numpy array to reflect the current full layout of faces within the face
|
|
viewer based on the currently selected filter and face thumbnail size.
|
|
|
|
Parameters
|
|
----------
|
|
canvas: :class:`tkinter.Canvas`
|
|
The :class:`~tools.manual.faceviewer.frame.FacesViewer` canvas
|
|
detected_faces: :class:`~tools.manual.detected_faces.DetectedFaces`
|
|
The :class:`~lib.align.DetectedFace` objects for this video
|
|
"""
|
|
def __init__(self, canvas, detected_faces):
|
|
logger.debug("Initializing %s: (detected_faces: %s)",
|
|
self.__class__.__name__, detected_faces)
|
|
self._canvas = canvas
|
|
self._detected_faces = detected_faces
|
|
self._raw_indices = detected_faces.filter.raw_indices
|
|
self._frames_list = detected_faces.filter.frames_list
|
|
|
|
self._is_valid = False
|
|
self._face_size = None
|
|
self._grid = None
|
|
self._display_faces = None
|
|
|
|
self._canvas.update_idletasks()
|
|
self._canvas.create_rectangle(0, 0, 0, 0, tags=["backdrop"])
|
|
self.update()
|
|
logger.debug("Initialized %s", self.__class__.__name__)
|
|
|
|
@property
|
|
def face_size(self):
|
|
""" int: The pixel size of each thumbnail within the face viewer. """
|
|
return self._face_size
|
|
|
|
@property
|
|
def is_valid(self):
|
|
""" bool: ``True`` if the current filter means that the grid holds faces. ``False`` if
|
|
there are no faces displayed in the grid. """
|
|
return self._is_valid
|
|
|
|
@property
|
|
def columns_rows(self):
|
|
""" tuple: the (`columns`, `rows`) required to hold all display images. """
|
|
retval = tuple(reversed(self._grid.shape[1:])) if self._is_valid else (0, 0)
|
|
return retval
|
|
|
|
@property
|
|
def dimensions(self):
|
|
""" tuple: The (`width`, `height`) required to hold all display images. """
|
|
if self._is_valid:
|
|
retval = tuple(dim * self._face_size for dim in reversed(self._grid.shape[1:]))
|
|
else:
|
|
retval = (0, 0)
|
|
return retval
|
|
|
|
@property
|
|
def _visible_row_indices(self):
|
|
"""tuple: A 1 dimensional array of the (`top_row_index`, `bottom_row_index`) of the grid
|
|
currently in the viewable area.
|
|
"""
|
|
height = self.dimensions[1]
|
|
visible = (max(0, floor(height * self._canvas.yview()[0]) - self._face_size),
|
|
ceil(height * self._canvas.yview()[1]))
|
|
logger.trace("height: %s, yview: %s, face_size: %s, visible: %s",
|
|
height, self._canvas.yview(), self._face_size, visible)
|
|
y_points = self._grid[3, :, 1]
|
|
top = np.searchsorted(y_points, visible[0], side="left")
|
|
bottom = np.searchsorted(y_points, visible[1], side="right")
|
|
return top, bottom
|
|
|
|
@property
|
|
def visible_area(self):
|
|
""":class:`numpy.ndarray`: A numpy array of shape (`4`, `rows`, `columns`) corresponding
|
|
to the viewable area of the display grid. 1st dimension contains frame indices, 2nd
|
|
dimension face indices. The 3rd and 4th dimension contain the x and y position of the top
|
|
left corner of the face respectively.
|
|
|
|
Any locations that are not populated by a face will have a frame and face index of -1
|
|
"""
|
|
if not self._is_valid:
|
|
retval = None, None
|
|
else:
|
|
top, bottom = self._visible_row_indices
|
|
retval = self._grid[:, top:bottom, :], self._display_faces[top:bottom, :]
|
|
logger.trace([r if r is None else r.shape for r in retval])
|
|
return retval
|
|
|
|
def y_coord_from_frame(self, frame_index):
|
|
""" Return the y coordinate for the first face that appears in the given frame.
|
|
|
|
Parameters
|
|
----------
|
|
frame_index: int
|
|
The frame index to locate in the grid
|
|
|
|
Returns
|
|
-------
|
|
int
|
|
The y coordinate of the first face for the given frame
|
|
"""
|
|
return min(self._grid[3][np.where(self._grid[0] == frame_index)])
|
|
|
|
def frame_has_faces(self, frame_index):
|
|
""" Check whether the given frame index contains any faces.
|
|
|
|
Parameters
|
|
----------
|
|
frame_index: int
|
|
The frame index to locate in the grid
|
|
|
|
Returns
|
|
-------
|
|
bool
|
|
``True`` if there are faces in the given frame otherwise ``False``
|
|
"""
|
|
return self._is_valid and np.any(self._grid[0] == frame_index)
|
|
|
|
def update(self):
|
|
""" Update the underlying grid.
|
|
|
|
Called on initialization, on a filter change or on add/remove faces. Recalculates the
|
|
underlying grid for the current filter view and updates the attributes :attr:`_grid`,
|
|
:attr:`_display_faces`, :attr:`_raw_indices`, :attr:`_frames_list` and :attr:`is_valid`
|
|
"""
|
|
self._face_size = self._canvas.face_size
|
|
self._raw_indices = self._detected_faces.filter.raw_indices
|
|
self._frames_list = self._detected_faces.filter.frames_list
|
|
self._get_grid()
|
|
self._get_display_faces()
|
|
self._canvas.coords("backdrop", 0, 0, *self.dimensions)
|
|
self._canvas.configure(scrollregion=(self._canvas.bbox("backdrop")))
|
|
self._canvas.yview_moveto(0.0)
|
|
|
|
def _get_grid(self):
|
|
""" Get the grid information for faces currently displayed in the :class:`FacesViewer`.
|
|
|
|
Returns
|
|
:class:`numpy.ndarray`
|
|
A numpy array of shape (`4`, `rows`, `columns`) corresponding to the display grid.
|
|
1st dimension contains frame indices, 2nd dimension face indices. The 3rd and 4th
|
|
dimension contain the x and y position of the top left corner of the face respectively.
|
|
|
|
Any locations that are not populated by a face will have a frame and face index of -1
|
|
"""
|
|
labels = self._get_labels()
|
|
if not self._is_valid:
|
|
logger.debug("Setting grid to None for no faces.")
|
|
self._grid = None
|
|
return
|
|
x_coords = np.linspace(0,
|
|
labels.shape[2] * self._face_size,
|
|
num=labels.shape[2],
|
|
endpoint=False,
|
|
dtype="int")
|
|
y_coords = np.linspace(0,
|
|
labels.shape[1] * self._face_size,
|
|
num=labels.shape[1],
|
|
endpoint=False,
|
|
dtype="int")
|
|
self._grid = np.array((*labels, *np.meshgrid(x_coords, y_coords)), dtype="int")
|
|
logger.debug(self._grid.shape)
|
|
|
|
def _get_labels(self):
|
|
""" Get the frame and face index for each grid position for the current filter.
|
|
|
|
Returns
|
|
-------
|
|
:class:`numpy.ndarray`
|
|
Array of dimensions (2, rows, columns) corresponding to the display grid, with frame
|
|
index as the first dimension and face index within the frame as the 2nd dimension.
|
|
|
|
Any remaining placeholders at the end of the grid which are not populated with a face
|
|
are given the index -1
|
|
"""
|
|
face_count = len(self._raw_indices["frame"])
|
|
self._is_valid = face_count != 0
|
|
if not self._is_valid:
|
|
return None
|
|
columns = self._canvas.winfo_width() // self._face_size
|
|
rows = ceil(face_count / columns)
|
|
remainder = face_count % columns
|
|
padding = [] if remainder == 0 else [-1 for _ in range(columns - remainder)]
|
|
labels = np.array((self._raw_indices["frame"] + padding,
|
|
self._raw_indices["face"] + padding),
|
|
dtype="int").reshape((2, rows, columns))
|
|
logger.debug("face-count: %s, columns: %s, rows: %s, remainder: %s, padding: %s, labels "
|
|
"shape: %s", face_count, columns, rows, remainder, padding, labels.shape)
|
|
return labels
|
|
|
|
def _get_display_faces(self):
|
|
""" Get the detected faces for the current filter and arrange to grid.
|
|
|
|
Returns
|
|
-------
|
|
:class:`numpy.ndarray`
|
|
Array of dimensions (rows, columns) corresponding to the display grid, containing the
|
|
corresponding :class:`lib.align.DetectFace` object
|
|
|
|
Any remaining placeholders at the end of the grid which are not populated with a face
|
|
are replaced with ``None``
|
|
"""
|
|
if not self._is_valid:
|
|
logger.debug("Setting display_faces to None for no faces.")
|
|
self._display_faces = None
|
|
return
|
|
current_faces = self._detected_faces.current_faces
|
|
columns, rows = self.columns_rows
|
|
face_count = len(self._raw_indices["frame"])
|
|
padding = [None for _ in range(face_count, columns * rows)]
|
|
self._display_faces = np.array([None if idx is None else current_faces[idx][face_idx]
|
|
for idx, face_idx
|
|
in zip(self._raw_indices["frame"] + padding,
|
|
self._raw_indices["face"] + padding)],
|
|
dtype="object").reshape(rows, columns)
|
|
logger.debug("faces: (shape: %s, dtype: %s)",
|
|
self._display_faces.shape, self._display_faces.dtype)
|
|
|
|
def transport_index_from_frame(self, frame_index):
|
|
""" Return the main frame's transport index for the given frame index based on the current
|
|
filter criteria.
|
|
|
|
Parameters
|
|
----------
|
|
frame_index: int
|
|
The absolute index for the frame within the full frames list
|
|
|
|
Returns
|
|
-------
|
|
int
|
|
The index of the requested frame within the filtered frames view.
|
|
"""
|
|
retval = self._frames_list.index(frame_index) if frame_index in self._frames_list else None
|
|
logger.trace("frame_index: %s, transport_index: %s", frame_index, retval)
|
|
return retval
|
|
|
|
|
|
class ContextMenu(): # pylint:disable=too-few-public-methods
|
|
""" Enables a right click context menu for the
|
|
:class:`~tools.manual.faceviewer.frame.FacesViewer`.
|
|
|
|
Parameters
|
|
----------
|
|
canvas: :class:`tkinter.Canvas`
|
|
The :class:`FacesViewer` canvas
|
|
detected_faces: :class:`~tools.manual.detected_faces`
|
|
The manual tool's detected faces class
|
|
"""
|
|
def __init__(self, canvas, detected_faces):
|
|
logger.debug("Initializing: %s (canvas: %s, detected_faces: %s)",
|
|
self.__class__.__name__, canvas, detected_faces)
|
|
self._canvas = canvas
|
|
self._detected_faces = detected_faces
|
|
self._menu = RightClickMenu(["Delete Face"], [self._delete_face])
|
|
self._frame_index = None
|
|
self._face_index = None
|
|
self._canvas.bind("<Button-2>" if platform.system() == "Darwin" else "<Button-3>",
|
|
self._pop_menu)
|
|
logger.debug("Initialized: %s", self.__class__.__name__)
|
|
|
|
def _pop_menu(self, event):
|
|
""" Pop up the context menu on a right click mouse event.
|
|
|
|
Parameters
|
|
----------
|
|
event: :class:`tkinter.Event`
|
|
The mouse event that has triggered the pop up menu
|
|
"""
|
|
frame_idx, face_idx = self._canvas.viewport.face_from_point(
|
|
self._canvas.canvasx(event.x), self._canvas.canvasy(event.y))[:2]
|
|
if frame_idx == -1:
|
|
logger.trace("No valid item under mouse")
|
|
self._frame_index = self._face_index = None
|
|
return
|
|
self._frame_index = frame_idx
|
|
self._face_index = face_idx
|
|
logger.trace("Popping right click menu")
|
|
self._menu.popup(event)
|
|
|
|
def _delete_face(self):
|
|
""" Delete the selected face on a right click mouse delete action. """
|
|
logger.trace("Right click delete received. frame_id: %s, face_id: %s",
|
|
self._frame_index, self._face_index)
|
|
self._detected_faces.update.delete(self._frame_index, self._face_index)
|
|
self._frame_index = self._face_index = None
|