mirror of
https://github.com/deepfakes/faceswap
synced 2025-06-07 10:43:27 -04:00
824 lines
35 KiB
Python
824 lines
35 KiB
Python
#!/usr/bin/env python3
|
|
""" The frame viewer section of the manual tool GUI """
|
|
import gettext
|
|
import logging
|
|
import tkinter as tk
|
|
from tkinter import ttk, TclError
|
|
|
|
from functools import partial
|
|
from time import time
|
|
|
|
from lib.gui.control_helper import set_slider_rounding
|
|
from lib.gui.custom_widgets import Tooltip
|
|
from lib.gui.utils import get_images
|
|
|
|
from .control import Navigation, BackgroundImage
|
|
from .editor import (BoundingBox, ExtractBox, Landmarks, Mask, # noqa pylint:disable=unused-import
|
|
Mesh, View)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# LOCALES
|
|
_LANG = gettext.translation("tools.manual", localedir="locales", fallback=True)
|
|
_ = _LANG.gettext
|
|
|
|
|
|
class DisplayFrame(ttk.Frame): # pylint:disable=too-many-ancestors
|
|
""" The main video display frame (top left section of GUI).
|
|
|
|
Parameters
|
|
----------
|
|
parent: :class:`ttk.PanedWindow`
|
|
The paned window that the display 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 detected faces stored in the alignments file
|
|
"""
|
|
def __init__(self, parent, tk_globals, detected_faces):
|
|
logger.debug("Initializing %s: (parent: %s, tk_globals: %s, detected_faces: %s)",
|
|
self.__class__.__name__, parent, tk_globals, detected_faces)
|
|
super().__init__(parent)
|
|
|
|
self._globals = tk_globals
|
|
self._det_faces = detected_faces
|
|
self._optional_widgets = {}
|
|
|
|
self._actions_frame = ActionsFrame(self)
|
|
main_frame = ttk.Frame(self)
|
|
|
|
self._transport_frame = ttk.Frame(main_frame)
|
|
self._nav = self._add_nav()
|
|
self._navigation = Navigation(self)
|
|
self._buttons = self._add_transport()
|
|
self._add_transport_tk_trace()
|
|
|
|
video_frame = ttk.Frame(main_frame)
|
|
video_frame.bind("<Configure>", self._resize)
|
|
|
|
self._canvas = FrameViewer(video_frame,
|
|
self._globals,
|
|
self._det_faces,
|
|
self._actions_frame.actions,
|
|
self._actions_frame.tk_selected_action)
|
|
|
|
self._actions_frame.add_optional_buttons(self.editors)
|
|
|
|
self._transport_frame.pack(side=tk.BOTTOM, padx=5, fill=tk.X)
|
|
video_frame.pack(side=tk.TOP, expand=True, fill=tk.BOTH)
|
|
main_frame.pack(side=tk.RIGHT, expand=True, fill=tk.BOTH)
|
|
self.pack(side=tk.LEFT, anchor=tk.NW, expand=True, fill=tk.BOTH)
|
|
|
|
logger.debug("Initialized %s", self.__class__.__name__)
|
|
|
|
@property
|
|
def _helptext(self):
|
|
""" dict: {`name`: `help text`} Helptext lookup for navigation buttons """
|
|
return {
|
|
"play": _("Play/Pause (SPACE)"),
|
|
"beginning": _("Go to First Frame (HOME)"),
|
|
"prev": _("Go to Previous Frame (Z)"),
|
|
"next": _("Go to Next Frame (X)"),
|
|
"end": _("Go to Last Frame (END)"),
|
|
"extract": _("Extract the faces to a folder... (Ctrl+E)"),
|
|
"save": _("Save the Alignments file (Ctrl+S)"),
|
|
"mode": _("Filter Frames to only those Containing the Selected Item (F)"),
|
|
"distance": _("Set the distance from an 'average face' to be considered misaligned. "
|
|
"Higher distances are more restrictive")}
|
|
|
|
@property
|
|
def _btn_action(self):
|
|
""" dict: {`name`: `action`} Command lookup for navigation buttons """
|
|
actions = {"play": self._navigation.handle_play_button,
|
|
"beginning": self._navigation.goto_first_frame,
|
|
"prev": self._navigation.decrement_frame,
|
|
"next": self._navigation.increment_frame,
|
|
"end": self._navigation.goto_last_frame,
|
|
"extract": self._det_faces.extract,
|
|
"save": self._det_faces.save}
|
|
return actions
|
|
|
|
@property
|
|
def tk_selected_action(self):
|
|
""" :class:`tkinter.StringVar`: The variable holding the currently selected action """
|
|
return self._actions_frame.tk_selected_action
|
|
|
|
@property
|
|
def active_editor(self):
|
|
""" :class:`Editor`: The current editor in use based on :attr:`selected_action`. """
|
|
return self._canvas.active_editor
|
|
|
|
@property
|
|
def editors(self):
|
|
""" dict: All of the :class:`Editor` that the canvas holds """
|
|
return self._canvas.editors
|
|
|
|
@property
|
|
def navigation(self):
|
|
""" :class:`~tools.manual.frameviewer.control.Navigation`: Class that handles frame
|
|
Navigation and transport. """
|
|
return self._navigation
|
|
|
|
@property
|
|
def tk_control_colors(self):
|
|
""" :dict: Editor key with :class:`tkinter.StringVar` containing the selected color hex
|
|
code for each annotation """
|
|
return {key: val["color"].tk_var for key, val in self._canvas.annotation_formats.items()}
|
|
|
|
@property
|
|
def tk_selected_mask(self):
|
|
""" :dict: Editor key with :class:`tkinter.StringVar` containing the selected color hex
|
|
code for each annotation """
|
|
return self._canvas.control_tk_vars["Mask"]["display"]["MaskType"]
|
|
|
|
@property
|
|
def _filter_modes(self):
|
|
""" list: The filter modes combo box values """
|
|
return ["All Frames", "Has Face(s)", "No Faces", "Multiple Faces", "Misaligned Faces"]
|
|
|
|
def _add_nav(self):
|
|
""" Add the slider to navigate through frames """
|
|
max_frame = self._globals.frame_count - 1
|
|
frame = ttk.Frame(self._transport_frame)
|
|
|
|
frame.pack(side=tk.TOP, fill=tk.X, pady=(0, 5))
|
|
lbl_frame = ttk.Frame(frame)
|
|
lbl_frame.pack(side=tk.RIGHT)
|
|
tbox = ttk.Entry(lbl_frame,
|
|
width=7,
|
|
textvariable=self._globals.var_transport_index,
|
|
justify=tk.RIGHT)
|
|
tbox.pack(padx=0, side=tk.LEFT)
|
|
lbl = ttk.Label(lbl_frame, text=f"/{max_frame}")
|
|
lbl.pack(side=tk.RIGHT)
|
|
|
|
cmd = partial(set_slider_rounding,
|
|
var=self._globals.var_transport_index,
|
|
d_type=int,
|
|
round_to=1,
|
|
min_max=(0, max_frame))
|
|
|
|
nav = ttk.Scale(frame,
|
|
variable=self._globals.var_transport_index,
|
|
from_=0,
|
|
to=max_frame,
|
|
command=cmd)
|
|
nav.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
self._globals.var_transport_index.trace_add("write", self._set_frame_index)
|
|
return {"entry": tbox, "scale": nav, "label": lbl}
|
|
|
|
def _set_frame_index(self, *args): # pylint:disable=unused-argument
|
|
""" Set the actual frame index based on current slider position and filter mode. """
|
|
try:
|
|
slider_position = self._globals.var_transport_index.get()
|
|
except TclError:
|
|
# don't update the slider when the entry box has been cleared of any value
|
|
return
|
|
frames = self._det_faces.filter.frames_list
|
|
actual_position = max(0, min(len(frames) - 1, slider_position))
|
|
if actual_position != slider_position:
|
|
self._globals.var_transport_index.set(actual_position)
|
|
frame_idx = frames[actual_position] if frames else -1
|
|
logger.trace("slider_position: %s, frame_idx: %s", actual_position, frame_idx)
|
|
self._globals.var_frame_index.set(frame_idx)
|
|
|
|
def _add_transport(self):
|
|
""" Add video transport controls """
|
|
frame = ttk.Frame(self._transport_frame)
|
|
frame.pack(side=tk.BOTTOM, fill=tk.X)
|
|
icons = get_images().icons
|
|
buttons = {}
|
|
for action in ("play", "beginning", "prev", "next", "end", "save", "extract", "mode"):
|
|
padx = (0, 6) if action in ("play", "prev", "mode") else (0, 0)
|
|
side = tk.RIGHT if action in ("extract", "save", "mode") else tk.LEFT
|
|
state = ["!disabled"] if action != "save" else ["disabled"]
|
|
if action != "mode":
|
|
icon = action if action != "extract" else "folder"
|
|
wgt = ttk.Button(frame, image=icons[icon], command=self._btn_action[action])
|
|
wgt.state(state)
|
|
else:
|
|
wgt = self._add_filter_section(frame)
|
|
wgt.pack(side=side, padx=padx)
|
|
if action != "mode":
|
|
Tooltip(wgt, text=self._helptext[action])
|
|
buttons[action] = wgt
|
|
logger.debug("Transport buttons: %s", buttons)
|
|
return buttons
|
|
|
|
def _add_transport_tk_trace(self):
|
|
""" Add the tkinter variable traces to buttons """
|
|
self._navigation.tk_is_playing.trace("w", self._play)
|
|
self._det_faces.tk_unsaved.trace("w", self._toggle_save_state)
|
|
|
|
def _add_filter_section(self, frame):
|
|
""" Add the section that holds the filter mode combo and any optional filter widgets
|
|
|
|
Parameters
|
|
----------
|
|
frame: :class:`tkinter.ttk.Frame`
|
|
The Frame that holds the filter section
|
|
|
|
Returns
|
|
-------
|
|
:class:`tkinter.ttk.Frame`
|
|
The filter section frame
|
|
"""
|
|
filter_frame = ttk.Frame(frame)
|
|
self._add_filter_mode_combo(filter_frame)
|
|
self._add_filter_threshold_slider(filter_frame)
|
|
filter_frame.pack(side=tk.RIGHT)
|
|
return filter_frame
|
|
|
|
def _add_filter_mode_combo(self, frame):
|
|
""" Add the navigation mode combo box to the filter frame.
|
|
|
|
Parameters
|
|
----------
|
|
frame: :class:`tkinter.ttk.Frame`
|
|
The Filter Frame that holds the filter combo box
|
|
"""
|
|
self._globals.var_filter_mode.set("All Frames")
|
|
self._globals.var_filter_mode.trace("w", self._navigation.nav_scale_callback)
|
|
nav_frame = ttk.Frame(frame)
|
|
lbl = ttk.Label(nav_frame, text="Filter:")
|
|
lbl.pack(side=tk.LEFT, padx=(0, 5))
|
|
combo = ttk.Combobox(
|
|
nav_frame,
|
|
textvariable=self._globals.var_filter_mode,
|
|
state="readonly",
|
|
values=self._filter_modes)
|
|
combo.pack(side=tk.RIGHT)
|
|
Tooltip(nav_frame, text=self._helptext["mode"])
|
|
nav_frame.pack(side=tk.RIGHT)
|
|
|
|
def _add_filter_threshold_slider(self, frame):
|
|
""" Add the optional filter threshold slider for misaligned filter to the filter frame.
|
|
|
|
Parameters
|
|
----------
|
|
frame: :class:`tkinter.ttk.Frame`
|
|
The Filter Frame that holds the filter threshold slider
|
|
"""
|
|
slider_frame = ttk.Frame(frame)
|
|
tk_var = self._globals.var_filter_distance
|
|
|
|
min_max = (5, 20)
|
|
ctl_frame = ttk.Frame(slider_frame)
|
|
ctl_frame.pack(padx=2, side=tk.RIGHT)
|
|
|
|
lbl = ttk.Label(ctl_frame, text="Distance:", anchor=tk.W)
|
|
lbl.pack(side=tk.LEFT, anchor=tk.N, expand=True)
|
|
|
|
tbox = ttk.Entry(ctl_frame, width=6, textvariable=tk_var, justify=tk.RIGHT)
|
|
tbox.pack(padx=(0, 5), side=tk.RIGHT)
|
|
|
|
ctl = ttk.Scale(
|
|
ctl_frame,
|
|
variable=tk_var,
|
|
command=lambda val, var=tk_var, dt=int, rn=1, mm=min_max:
|
|
set_slider_rounding(val, var, dt, rn, mm))
|
|
ctl["from_"] = min_max[0]
|
|
ctl["to"] = min_max[1]
|
|
ctl.pack(padx=5, fill=tk.X, expand=True)
|
|
for item in (tbox, ctl):
|
|
Tooltip(item,
|
|
text=self._helptext["distance"],
|
|
wrap_length=200)
|
|
tk_var.trace_add("write", self._navigation.nav_scale_callback)
|
|
self._optional_widgets["distance_slider"] = slider_frame
|
|
|
|
def pack_threshold_slider(self):
|
|
""" Display or hide the threshold slider depending on the current filter mode. For
|
|
misaligned faces filter, display the slider. Hide for all other filters. """
|
|
if self._globals.var_filter_mode.get() == "Misaligned Faces":
|
|
self._optional_widgets["distance_slider"].pack(side=tk.LEFT)
|
|
else:
|
|
self._optional_widgets["distance_slider"].pack_forget()
|
|
|
|
def cycle_filter_mode(self):
|
|
""" Cycle the navigation mode combo entry """
|
|
current_mode = self._globals.var_filter_mode.get()
|
|
idx = (self._filter_modes.index(current_mode) + 1) % len(self._filter_modes)
|
|
self._globals.var_filter_mode.set(self._filter_modes[idx])
|
|
|
|
def set_action(self, key):
|
|
""" Set the current action based on keyboard shortcut
|
|
|
|
Parameters
|
|
----------
|
|
key: str
|
|
The pressed key
|
|
"""
|
|
# Allow key pad keys for numeric presses
|
|
key = key.replace("KP_", "") if key.startswith("KP_") else key
|
|
self._actions_frame.on_click(self._actions_frame.key_bindings[key])
|
|
|
|
def _resize(self, event):
|
|
""" Resize the image to fit the frame, maintaining aspect ratio """
|
|
framesize = (event.width, event.height)
|
|
logger.trace("Resizing video frame. Framesize: %s", framesize)
|
|
self._globals.set_frame_display_dims(*framesize)
|
|
self._globals.var_full_update.set(True)
|
|
|
|
# << TRANSPORT >> #
|
|
def _play(self, *args, frame_count=None): # pylint:disable=unused-argument
|
|
""" Play the video file. """
|
|
start = time()
|
|
is_playing = self._navigation.tk_is_playing.get()
|
|
icon = "pause" if is_playing else "play"
|
|
self._buttons["play"].config(image=get_images().icons[icon])
|
|
|
|
if not is_playing:
|
|
logger.debug("Pause detected. Stopping.")
|
|
return
|
|
|
|
# Populate the filtered frames count on first frame
|
|
frame_count = self._det_faces.filter.count if frame_count is None else frame_count
|
|
self._navigation.increment_frame(frame_count=frame_count, is_playing=True)
|
|
delay = 16 # Cap speed at approx 60fps max. Unlikely to hit, but just in case
|
|
duration = int((time() - start) * 1000)
|
|
delay = max(1, delay - duration)
|
|
self.after(delay, lambda f=frame_count: self._play(f))
|
|
|
|
def _toggle_save_state(self, *args): # pylint:disable=unused-argument
|
|
""" Toggle the state of the save button when alignments are updated. """
|
|
state = ["!disabled"] if self._det_faces.tk_unsaved.get() else ["disabled"]
|
|
self._buttons["save"].state(state)
|
|
|
|
|
|
class ActionsFrame(ttk.Frame): # pylint:disable=too-many-ancestors
|
|
""" The left hand action frame holding the action buttons.
|
|
|
|
Parameters
|
|
----------
|
|
parent: :class:`DisplayFrame`
|
|
The Display frame that the Actions reside in
|
|
"""
|
|
def __init__(self, parent):
|
|
super().__init__(parent)
|
|
self.pack(side=tk.LEFT, fill=tk.Y, padx=(2, 4), pady=2)
|
|
self._globals = parent._globals
|
|
self._det_faces = parent._det_faces
|
|
|
|
self._configure_styles()
|
|
self._actions = ("View", "BoundingBox", "ExtractBox", "Landmarks", "Mask")
|
|
self._initial_action = "View"
|
|
self._buttons = self._add_buttons()
|
|
self._static_buttons = self._add_static_buttons()
|
|
self._selected_action = self._set_selected_action_tkvar()
|
|
self._optional_buttons = {} # Has to be set from parent after canvas is initialized
|
|
|
|
@property
|
|
def actions(self):
|
|
""" tuple: The available action names as a tuple of strings. """
|
|
return self._actions
|
|
|
|
@property
|
|
def tk_selected_action(self):
|
|
""" :class:`tkinter.StringVar`: The variable holding the currently selected action """
|
|
return self._selected_action
|
|
|
|
@property
|
|
def key_bindings(self):
|
|
""" dict: {`key`: `action`}. The mapping of key presses to actions. Keyboard shortcut is
|
|
the first letter of each action. """
|
|
return {f"F{idx + 1}": action for idx, action in enumerate(self._actions)}
|
|
|
|
@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 = {"View": _('View alignments'),
|
|
"BoundingBox": _('Bounding box editor'),
|
|
"ExtractBox": _("Location editor"),
|
|
"Mask": _("Mask editor"),
|
|
"Landmarks": _("Landmark point editor")}
|
|
for item in retval:
|
|
retval[item] += f" ({inverse_keybindings[item]})"
|
|
return retval
|
|
|
|
def _configure_styles(self):
|
|
""" Configure background color for Actions widget """
|
|
style = ttk.Style()
|
|
style.configure("actions.TFrame", background='#d3d3d3')
|
|
style.configure("actions_selected.TButton", relief="flat", background="#bedaf1")
|
|
style.configure("actions_deselected.TButton", relief="flat")
|
|
self.config(style="actions.TFrame")
|
|
|
|
def _add_buttons(self):
|
|
""" Add the action buttons to the Display window.
|
|
|
|
Returns
|
|
-------
|
|
dict:
|
|
The action name and its associated button.
|
|
"""
|
|
frame = ttk.Frame(self)
|
|
frame.pack(side=tk.TOP, fill=tk.Y)
|
|
buttons = {}
|
|
for action in self.key_bindings.values():
|
|
if action == self._initial_action:
|
|
btn_style = "actions_selected.TButton"
|
|
state = (["pressed", "focus"])
|
|
else:
|
|
btn_style = "actions_deselected.TButton"
|
|
state = (["!pressed", "!focus"])
|
|
|
|
button = ttk.Button(frame,
|
|
image=get_images().icons[action.lower()],
|
|
command=lambda t=action: self.on_click(t),
|
|
style=btn_style)
|
|
button.state(state)
|
|
button.pack()
|
|
Tooltip(button, text=self._helptext[action])
|
|
buttons[action] = button
|
|
return buttons
|
|
|
|
def on_click(self, action):
|
|
""" Click event for all of the main buttons.
|
|
|
|
Parameters
|
|
----------
|
|
action: str
|
|
The action name for the button that has called this event as exists in :attr:`_buttons`
|
|
"""
|
|
for title, button in self._buttons.items():
|
|
if action == title:
|
|
button.configure(style="actions_selected.TButton")
|
|
button.state(["pressed", "focus"])
|
|
else:
|
|
button.configure(style="actions_deselected.TButton")
|
|
button.state(["!pressed", "!focus"])
|
|
self._selected_action.set(action)
|
|
|
|
def _set_selected_action_tkvar(self):
|
|
""" Set the tkinter string variable that holds the currently selected editor action.
|
|
Add traceback to display or hide editor specific optional buttons.
|
|
|
|
Returns
|
|
-------
|
|
:class:`tkinter.StringVar
|
|
The variable that holds the currently selected action
|
|
"""
|
|
var = tk.StringVar()
|
|
var.set(self._initial_action)
|
|
var.trace("w", self._display_optional_buttons)
|
|
return var
|
|
|
|
def _add_static_buttons(self):
|
|
""" Add the buttons to copy alignments from previous and next frames """
|
|
lookup = {"copy_prev": (_("Previous"), "C"),
|
|
"copy_next": (_("Next"), "V"),
|
|
"reload": ("", "R")}
|
|
frame = ttk.Frame(self)
|
|
frame.pack(side=tk.TOP, fill=tk.Y)
|
|
sep = ttk.Frame(frame, height=2, relief=tk.RIDGE)
|
|
sep.pack(fill=tk.X, pady=5, side=tk.TOP)
|
|
buttons = {}
|
|
for action in ("copy_prev", "copy_next", "reload"):
|
|
if action == "reload":
|
|
icon = "reload3"
|
|
cmd = lambda f=self._globals: self._det_faces.revert_to_saved(f.frame_index) # noqa:E731,E501 # pylint:disable=line-too-long,unnecessary-lambda-assignment
|
|
helptext = _("Revert to saved Alignments ({})").format(lookup[action][1])
|
|
else:
|
|
icon = action
|
|
direction = action.replace("copy_", "")
|
|
cmd = lambda f=self._globals, d=direction: self._det_faces.update.copy( # noqa:E731,E501 # pylint:disable=line-too-long,unnecessary-lambda-assignment
|
|
f.frame_index, d)
|
|
helptext = _("Copy {} Alignments ({})").format(*lookup[action])
|
|
state = ["!disabled"] if action == "copy_next" else ["disabled"]
|
|
button = ttk.Button(frame,
|
|
image=get_images().icons[icon],
|
|
command=cmd,
|
|
style="actions_deselected.TButton")
|
|
button.state(state)
|
|
button.pack()
|
|
Tooltip(button, text=helptext)
|
|
buttons[action] = button
|
|
self._globals.var_frame_index.trace_add("write", self._disable_enable_copy_buttons)
|
|
self._globals.var_full_update.trace_add("write", self._disable_enable_reload_button)
|
|
return buttons
|
|
|
|
def _disable_enable_copy_buttons(self, *args): # pylint:disable=unused-argument
|
|
""" Disable or enable the static buttons """
|
|
position = self._globals.frame_index
|
|
face_count_per_index = self._det_faces.face_count_per_index
|
|
prev_exists = position != -1 and any(count != 0
|
|
for count in face_count_per_index[:position])
|
|
next_exists = position != -1 and any(count != 0
|
|
for count in face_count_per_index[position + 1:])
|
|
states = {"prev": ["!disabled"] if prev_exists else ["disabled"],
|
|
"next": ["!disabled"] if next_exists else ["disabled"]}
|
|
for direction in ("prev", "next"):
|
|
self._static_buttons[f"copy_{direction}"].state(states[direction])
|
|
|
|
def _disable_enable_reload_button(self, *args): # pylint:disable=unused-argument
|
|
""" Disable or enable the static buttons """
|
|
position = self._globals.frame_index
|
|
state = ["!disabled"] if (position != -1 and
|
|
self._det_faces.is_frame_updated(position)) else ["disabled"]
|
|
self._static_buttons["reload"].state(state)
|
|
|
|
def add_optional_buttons(self, editors):
|
|
""" Add the optional editor specific action buttons """
|
|
for name, editor in editors.items():
|
|
actions = editor.actions
|
|
if not actions:
|
|
self._optional_buttons[name] = None
|
|
continue
|
|
frame = ttk.Frame(self)
|
|
sep = ttk.Frame(frame, height=2, relief=tk.RIDGE)
|
|
sep.pack(fill=tk.X, pady=5, side=tk.TOP)
|
|
seen_groups = set()
|
|
for action in actions.values():
|
|
group = action["group"]
|
|
if group is not None and group not in seen_groups:
|
|
btn_style = "actions_selected.TButton"
|
|
state = (["pressed", "focus"])
|
|
action["tk_var"].set(True)
|
|
seen_groups.add(group)
|
|
else:
|
|
btn_style = "actions_deselected.TButton"
|
|
state = (["!pressed", "!focus"])
|
|
action["tk_var"].set(False)
|
|
button = ttk.Button(frame,
|
|
image=get_images().icons[action["icon"]],
|
|
style=btn_style)
|
|
button.config(command=lambda b=button: self._on_optional_click(b))
|
|
button.state(state)
|
|
button.pack()
|
|
|
|
helptext = action["helptext"]
|
|
hotkey = action["hotkey"]
|
|
helptext += "" if hotkey is None else f" ({hotkey.upper()})"
|
|
Tooltip(button, text=helptext)
|
|
self._optional_buttons.setdefault(
|
|
name, {})[button] = {"hotkey": hotkey,
|
|
"group": group,
|
|
"tk_var": action["tk_var"]}
|
|
self._optional_buttons[name]["frame"] = frame
|
|
self._display_optional_buttons()
|
|
|
|
def _on_optional_click(self, button):
|
|
""" Click event for all of the optional buttons.
|
|
|
|
Parameters
|
|
----------
|
|
button: str
|
|
The action name for the button that has called this event as exists in :attr:`_buttons`
|
|
"""
|
|
options = self._optional_buttons[self._selected_action.get()]
|
|
group = options[button]["group"]
|
|
for child in options["frame"].winfo_children():
|
|
if child.winfo_class() != "TButton":
|
|
continue
|
|
child_group = options[child]["group"]
|
|
if child == button and group is not None:
|
|
child.configure(style="actions_selected.TButton")
|
|
child.state(["pressed", "focus"])
|
|
options[child]["tk_var"].set(True)
|
|
elif child != button and group is not None and child_group == group:
|
|
child.configure(style="actions_deselected.TButton")
|
|
child.state(["!pressed", "!focus"])
|
|
options[child]["tk_var"].set(False)
|
|
elif group is None and child_group is None:
|
|
if child.cget("style") == "actions_selected.TButton":
|
|
child.configure(style="actions_deselected.TButton")
|
|
child.state(["!pressed", "!focus"])
|
|
options[child]["tk_var"].set(False)
|
|
else:
|
|
child.configure(style="actions_selected.TButton")
|
|
child.state(["pressed", "focus"])
|
|
options[child]["tk_var"].set(True)
|
|
|
|
def _display_optional_buttons(self, *args): # pylint:disable=unused-argument
|
|
""" Pack or forget the optional buttons depending on active editor """
|
|
self._unbind_optional_hotkeys()
|
|
for editor, option in self._optional_buttons.items():
|
|
if option is None:
|
|
continue
|
|
if editor == self._selected_action.get():
|
|
logger.debug("Displaying optional buttons for '%s'", editor)
|
|
option["frame"].pack(side=tk.TOP, fill=tk.Y)
|
|
for child in option["frame"].winfo_children():
|
|
if child.winfo_class() != "TButton":
|
|
continue
|
|
hotkey = option[child]["hotkey"]
|
|
if hotkey is not None:
|
|
logger.debug("Binding optional hotkey for editor '%s': %s", editor, hotkey)
|
|
self.winfo_toplevel().bind(hotkey.lower(),
|
|
lambda e, b=child: self._on_optional_click(b))
|
|
elif option["frame"].winfo_ismapped():
|
|
logger.debug("Hiding optional buttons for '%s'", editor)
|
|
option["frame"].pack_forget()
|
|
|
|
def _unbind_optional_hotkeys(self):
|
|
""" Unbind all mapped optional button hotkeys """
|
|
for editor, option in self._optional_buttons.items():
|
|
if option is None or not option["frame"].winfo_ismapped():
|
|
continue
|
|
for child in option["frame"].winfo_children():
|
|
if child.winfo_class() != "TButton":
|
|
continue
|
|
hotkey = option[child]["hotkey"]
|
|
if hotkey is not None:
|
|
logger.debug("Unbinding optional hotkey for editor '%s': %s", editor, hotkey)
|
|
self.winfo_toplevel().unbind(hotkey.lower())
|
|
|
|
|
|
class FrameViewer(tk.Canvas): # pylint:disable=too-many-ancestors
|
|
""" Annotation onto tkInter Canvas.
|
|
|
|
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
|
|
detected_faces: :class:`AlignmentsData`
|
|
The alignments data for this manual session
|
|
actions: tuple
|
|
The available actions from :attr:`ActionFrame.actions`
|
|
tk_action_var: :class:`tkinter.StringVar`
|
|
The variable holding the currently selected action
|
|
"""
|
|
def __init__(self, parent, tk_globals, detected_faces, actions, tk_action_var):
|
|
logger.debug("Initializing %s: (parent: %s, tk_globals: %s, detected_faces: %s, "
|
|
"actions: %s, tk_action_var: %s)", self.__class__.__name__,
|
|
parent, tk_globals, detected_faces, actions, tk_action_var)
|
|
super().__init__(parent, bd=0, highlightthickness=0, background="black")
|
|
self.pack(side=tk.TOP, fill=tk.BOTH, expand=True, anchor=tk.E)
|
|
self._globals = tk_globals
|
|
self._det_faces = detected_faces
|
|
self._actions = actions
|
|
self._tk_action_var = tk_action_var
|
|
self._image = BackgroundImage(self)
|
|
self._editor_globals = {"control_tk_vars": {},
|
|
"annotation_formats": {},
|
|
"key_bindings": {}}
|
|
self._max_face_count = 0
|
|
self._editors = self._get_editors()
|
|
self._add_callbacks()
|
|
self._change_active_editor()
|
|
logger.debug("Initialized %s", self.__class__.__name__)
|
|
|
|
@property
|
|
def selected_action(self):
|
|
"""str: The name of the currently selected Editor action """
|
|
return self._tk_action_var.get()
|
|
|
|
@property
|
|
def control_tk_vars(self):
|
|
""" dict: dictionary of tkinter variables as populated by the right hand control panel.
|
|
Tracking for all control panel variables, for access from all editors. """
|
|
return self._editor_globals["control_tk_vars"]
|
|
|
|
@property
|
|
def key_bindings(self):
|
|
""" dict: dictionary of key bindings for each editor for access from all editors. """
|
|
return self._editor_globals["key_bindings"]
|
|
|
|
@property
|
|
def annotation_formats(self):
|
|
""" dict: The selected formatting options for each annotation """
|
|
return self._editor_globals["annotation_formats"]
|
|
|
|
@property
|
|
def active_editor(self):
|
|
""" :class:`Editor`: The current editor in use based on :attr:`selected_action`. """
|
|
return self._editors[self.selected_action]
|
|
|
|
@property
|
|
def editors(self):
|
|
""" dict: All of the :class:`Editor` objects that exist """
|
|
return self._editors
|
|
|
|
@property
|
|
def editor_display(self):
|
|
""" dict: List of editors and any additional annotations they should display. """
|
|
return {"View": ["BoundingBox", "ExtractBox", "Landmarks", "Mesh"],
|
|
"BoundingBox": ["Mesh"],
|
|
"ExtractBox": ["Mesh"],
|
|
"Landmarks": ["ExtractBox", "Mesh"],
|
|
"Mask": []}
|
|
|
|
@property
|
|
def offset(self):
|
|
""" tuple: The (`width`, `height`) offset of the canvas based on the size of the currently
|
|
displayed image """
|
|
frame_dims = self._globals.current_frame.display_dims
|
|
offset_x = (self._globals.frame_display_dims[0] - frame_dims[0]) / 2
|
|
offset_y = (self._globals.frame_display_dims[1] - frame_dims[1]) / 2
|
|
logger.trace("offset_x: %s, offset_y: %s", offset_x, offset_y)
|
|
return offset_x, offset_y
|
|
|
|
def _get_editors(self):
|
|
""" Get the object editors for the canvas.
|
|
|
|
Returns
|
|
------
|
|
dict
|
|
The {`action`: :class:`Editor`} dictionary of editors for :attr:`_actions` name.
|
|
"""
|
|
editors = {}
|
|
for editor_name in self._actions + ("Mesh", ):
|
|
editor = eval(editor_name)(self, # pylint:disable=eval-used
|
|
self._det_faces)
|
|
editors[editor_name] = editor
|
|
logger.debug(editors)
|
|
return editors
|
|
|
|
def _add_callbacks(self):
|
|
""" Add the callback trace functions to the :class:`tkinter.Variable` s
|
|
|
|
Adds callbacks for:
|
|
:attr:`_globals.var_full_update` Update the display for the current image
|
|
:attr:`__tk_action_var` Update the mouse display tracking for current action
|
|
"""
|
|
self._globals.var_full_update.trace_add("write", self._update_display)
|
|
self._tk_action_var.trace_add("write", self._change_active_editor)
|
|
|
|
def _change_active_editor(self, *args): # pylint:disable=unused-argument
|
|
""" Update the display for the active editor.
|
|
|
|
Hide the annotations that are not relevant for the selected editor.
|
|
Set the selected editor's cursor tracking.
|
|
|
|
Parameters
|
|
----------
|
|
args: tuple, unused
|
|
Required for tkinter callback but unused
|
|
"""
|
|
to_display = [self.selected_action] + self.editor_display[self.selected_action]
|
|
to_hide = [editor for editor in self._editors if editor not in to_display]
|
|
for editor in to_hide:
|
|
self._editors[editor].hide_annotation()
|
|
|
|
self.active_editor.bind_mouse_motion()
|
|
self.active_editor.set_mouse_click_actions()
|
|
self._globals.var_full_update.set(True)
|
|
|
|
def _update_display(self, *args): # pylint:disable=unused-argument
|
|
""" Update the display on frame cache update
|
|
|
|
Notes
|
|
-----
|
|
A little hacky, but the editors to display or hide are processed in alphabetical
|
|
order, so that they are always processed in the same order (for tag lowering and raising)
|
|
"""
|
|
if not self._globals.var_full_update.get():
|
|
return
|
|
zoomed_centering = self.active_editor.zoomed_centering
|
|
self._image.refresh(self.active_editor.view_mode)
|
|
to_display = sorted([self.selected_action] + self.editor_display[self.selected_action])
|
|
self._hide_additional_faces()
|
|
for editor in to_display:
|
|
self._editors[editor].update_annotation()
|
|
self._bind_unbind_keys()
|
|
if zoomed_centering != self.active_editor.zoomed_centering:
|
|
# Refresh the image if editor annotation has changed the zoom centering of the image
|
|
self._image.refresh(self.active_editor.view_mode)
|
|
self._globals.var_full_update.set(False)
|
|
self.update_idletasks()
|
|
|
|
def _hide_additional_faces(self):
|
|
""" Hide additional faces if the number of faces on the canvas reduces on a frame
|
|
change. """
|
|
if self._globals.is_zoomed:
|
|
current_face_count = 1
|
|
elif self._globals.frame_index == -1:
|
|
current_face_count = 0
|
|
else:
|
|
current_face_count = len(self._det_faces.current_faces[self._globals.frame_index])
|
|
|
|
if current_face_count > self._max_face_count:
|
|
# Most faces seen to date so nothing to hide. Update max count and return
|
|
logger.debug("Incrementing max face count from: %s to: %s",
|
|
self._max_face_count, current_face_count)
|
|
self._max_face_count = current_face_count
|
|
return
|
|
for idx in range(current_face_count, self._max_face_count):
|
|
tag = f"face_{idx}"
|
|
if any(self.itemcget(item_id, "state") != "hidden"
|
|
for item_id in self.find_withtag(tag)):
|
|
logger.debug("Hiding face tag '%s'", tag)
|
|
self.itemconfig(tag, state="hidden")
|
|
|
|
def _bind_unbind_keys(self):
|
|
""" Bind or unbind this editor's hotkeys depending on whether it is active. """
|
|
unbind_keys = [key for key, binding in self.key_bindings.items()
|
|
if binding["bound_to"] is not None
|
|
and binding["bound_to"] != self.selected_action]
|
|
for key in unbind_keys:
|
|
logger.debug("Unbinding key '%s'", key)
|
|
self.winfo_toplevel().unbind(key)
|
|
self.key_bindings[key]["bound_to"] = None
|
|
|
|
bind_keys = {key: binding[self.selected_action]
|
|
for key, binding in self.key_bindings.items()
|
|
if self.selected_action in binding
|
|
and binding["bound_to"] != self.selected_action}
|
|
for key, method in bind_keys.items():
|
|
logger.debug("Binding key '%s' to method %s", key, method)
|
|
self.winfo_toplevel().bind(key, method)
|
|
self.key_bindings[key]["bound_to"] = self.selected_action
|