1
0
Fork 0
mirror of https://github.com/deepfakes/faceswap synced 2025-06-07 10:43:27 -04:00
faceswap/tools/manual/faceviewer/frame.py
2024-04-03 14:03:54 +01:00

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