faceswap/lib/gui/utils.py
2020-11-01 00:44:12 +00:00

1199 lines
46 KiB
Python

#!/usr/bin/env python3
""" Utility functions for the GUI """
import logging
import os
import platform
import sys
import tkinter as tk
from tkinter import filedialog
from threading import Event, Thread
from queue import Queue
import numpy as np
from PIL import Image, ImageDraw, ImageTk
from ._config import Config as UserConfig
from .project import Project, Tasks
logger = logging.getLogger(__name__) # pylint: disable=invalid-name
_CONFIG = None
_IMAGES = None
_PREVIEW_TRIGGER = None
PATHCACHE = os.path.join(os.path.realpath(os.path.dirname(sys.argv[0])), "lib", "gui", ".cache")
def initialize_config(root, cli_opts, statusbar):
""" Initialize the GUI Master :class:`Config` and add to global constant.
This should only be called once on first GUI startup. Future access to :class:`Config`
should only be executed through :func:`get_config`.
Parameters
----------
root: :class:`tkinter.Tk`
The root Tkinter object
cli_opts: :class:`lib.gui.options.CliOpts`
The command line options object
statusbar: :class:`lib.gui.custom_widgets.StatusBar`
The GUI Status bar
"""
global _CONFIG # pylint: disable=global-statement
if _CONFIG is not None:
return None
logger.debug("Initializing config: (root: %s, cli_opts: %s, "
"statusbar: %s)", root, cli_opts, statusbar)
_CONFIG = Config(root, cli_opts, statusbar)
return _CONFIG
def get_config():
""" Get the Master GUI configuration.
Returns
-------
:class:`Config`
The Master GUI Config
"""
return _CONFIG
def initialize_images():
""" Initialize the :class:`Images` handler and add to global constant.
This should only be called once on first GUI startup. Future access to :class:`Images`
handler should only be executed through :func:`get_images`.
"""
global _IMAGES # pylint: disable=global-statement
if _IMAGES is not None:
return
logger.debug("Initializing images")
_IMAGES = Images()
def get_images():
""" Get the Master GUI Images handler.
Returns
-------
:class:`Images`
The Master GUI Images handler
"""
return _IMAGES
class FileHandler(): # pylint:disable=too-few-public-methods
""" Handles all GUI File Dialog actions and tasks.
Parameters
----------
handle_type: ['open', 'save', 'filename', 'filename_multi', 'savefilename', 'context', `dir`]
The type of file dialog to return. `open` and `save` will perform the open and save actions
and return the file. `filename` returns the filename from an `open` dialog.
`filename_multi` allows for multi-selection of files and returns a list of files selected.
`savefilename` returns the filename from a `save as` dialog. `context` is a context
sensitive parameter that returns a certain dialog based on the current options. `dir` asks
for a folder location.
file_type: ['default', 'alignments', 'config_project', 'config_task', 'config_all', 'csv', \
'image', 'ini', 'state', 'log', 'video']
The type of file that this dialog is for. `default` allows selection of any files. Other
options limit the file type selection
title: str, optional
The title to display on the file dialog. If `None` then the default title will be used.
Default: ``None``
initial_folder: str, optional
The folder to initially open with the file dialog. If `None` then tkinter will decide.
Default: ``None``
command: str, optional
Required for context handling file dialog, otherwise unused. Default: ``None``
action: str, optional
Required for context handling file dialog, otherwise unused. Default: ``None``
variable: :class:`tkinter.StringVar`, optional
Required for context handling file dialog, otherwise unused. The variable to associate
with this file dialog. Default: ``None``
Attributes
----------
retfile: str or object
The return value from the file dialog
Example
-------
>>> handler = FileHandler('filename', 'video', title='Select a video...')
>>> video_file = handler.retfile
>>> print(video_file)
'/path/to/selected/video.mp4'
"""
def __init__(self, handle_type, file_type, title=None, initial_folder=None, command=None,
action=None, variable=None):
logger.debug("Initializing %s: (handle_type: '%s', file_type: '%s', title: '%s', "
"initial_folder: '%s, 'command: '%s', action: '%s', variable: %s)",
self.__class__.__name__, handle_type, file_type, title, initial_folder,
command, action, variable)
self._handletype = handle_type
self._defaults = self._set_defaults()
self._kwargs = self._set_kwargs(title,
initial_folder,
file_type,
command,
action,
variable)
self.retfile = getattr(self, "_{}".format(self._handletype.lower()))()
logger.debug("Initialized %s", self.__class__.__name__)
@property
def _filetypes(self):
""" dict: The accepted extensions for each file type for opening/saving """
all_files = ("All files", "*.*")
filetypes = {"default": (all_files,),
"alignments": [("Faceswap Alignments", "*.fsa"),
all_files],
"config_project": [("Faceswap Project files", "*.fsw"), all_files],
"config_task": [("Faceswap Task files", "*.fst"), all_files],
"config_all": [("Faceswap Project and Task files", "*.fst *.fsw"), all_files],
"csv": [("Comma separated values", "*.csv"), all_files],
"image": [("Bitmap", "*.bmp"),
("JPG", "*.jpeg *.jpg"),
("PNG", "*.png"),
("TIFF", "*.tif *.tiff"),
all_files],
"ini": [("Faceswap config files", "*.ini"), all_files],
"state": [("State files", "*.json"), all_files],
"log": [("Log files", "*.log"), all_files],
"video": [("Audio Video Interleave", "*.avi"),
("Flash Video", "*.flv"),
("Matroska", "*.mkv"),
("MOV", "*.mov"),
("MP4", "*.mp4"),
("MPEG", "*.mpeg *.mpg *.ts *.vob"),
("WebM", "*.webm"),
("Windows Media Video", "*.wmv"),
all_files]}
# Add in multi-select options and upper case extensions for Linux
for key in filetypes:
if platform.system() == "Linux":
filetypes[key] = [item
if item[0] == "All files"
else (item[0], "{} {}".format(item[1], item[1].upper()))
for item in filetypes[key]]
if len(filetypes[key]) > 2:
multi = ["{} Files".format(key.title())]
multi.append(" ".join([ftype[1]
for ftype in filetypes[key] if ftype[0] != "All files"]))
filetypes[key].insert(0, tuple(multi))
return filetypes
@property
def _contexts(self):
"""dict: Mapping of commands, actions and their corresponding file dialog for context
handle types. """
return {
"effmpeg": {
"input": {
"extract": "filename",
"gen-vid": "dir",
"get-fps": "filename",
"get-info": "filename",
"mux-audio": "filename",
"rescale": "filename",
"rotate": "filename",
"slice": "filename"},
"output": {
"extract": "dir",
"gen-vid": "savefilename",
"get-fps": "nothing",
"get-info": "nothing",
"mux-audio": "savefilename",
"rescale": "savefilename",
"rotate": "savefilename",
"slice": "savefilename"}
}
}
def _set_defaults(self):
""" Set the default file type for the file dialog. Generally the first found file type
will be used, but this is overridden if it is not appropriate.
Returns
-------
dict:
The default file extension for each file type
"""
defaults = {key: val[0][1].replace("*", "")
for key, val in self._filetypes.items()}
defaults["default"] = None
defaults["video"] = ".mp4"
defaults["image"] = ".png"
logger.debug(defaults)
return defaults
def _set_kwargs(self, title, initialdir, filetype, command, action, variable=None):
""" Generate the required kwargs for the requested file dialog browser.
Returns
-------
dict:
The key word arguments for the file dialog to be launched
"""
logger.debug("Setting Kwargs: (title: %s, initialdir: %s, filetype: '%s', "
"command: '%s': action: '%s', variable: '%s')",
title, initialdir, filetype, command, action, variable)
kwargs = dict()
if self._handletype.lower() == "context":
self._set_context_handletype(command, action, variable)
if title is not None:
kwargs["title"] = title
if initialdir is not None:
kwargs["initialdir"] = initialdir
if self._handletype.lower() in (
"open", "save", "filename", "filename_multi", "savefilename"):
kwargs["filetypes"] = self._filetypes[filetype]
if self._defaults.get(filetype, None):
kwargs['defaultextension'] = self._defaults[filetype]
if self._handletype.lower() == "save":
kwargs["mode"] = "w"
if self._handletype.lower() == "open":
kwargs["mode"] = "r"
logger.debug("Set Kwargs: %s", kwargs)
return kwargs
def _set_context_handletype(self, command, action, variable):
""" Sets the correct handle type based on context.
Parameters
----------
command: str
The command that is being executed. Used to look up the context actions
action: str
The action that is being performed. Used to look up the correct file dialog
variable: :class:`tkinter.StringVar`
The variable associated with this file dialog
"""
if self._contexts[command].get(variable, None) is not None:
handletype = self._contexts[command][variable][action]
else:
handletype = self._contexts[command][action]
logger.debug(handletype)
self._handletype = handletype
def _open(self):
""" Open a file. """
logger.debug("Popping Open browser")
return filedialog.askopenfile(**self._kwargs)
def _save(self):
""" Save a file. """
logger.debug("Popping Save browser")
return filedialog.asksaveasfile(**self._kwargs)
def _dir(self):
""" Get a directory location. """
logger.debug("Popping Dir browser")
return filedialog.askdirectory(**self._kwargs)
def _savedir(self):
""" Get a save directory location. """
logger.debug("Popping SaveDir browser")
return filedialog.askdirectory(**self._kwargs)
def _filename(self):
""" Get an existing file location. """
logger.debug("Popping Filename browser")
return filedialog.askopenfilename(**self._kwargs)
def _filename_multi(self):
""" Get multiple existing file locations. """
logger.debug("Popping Filename browser")
return filedialog.askopenfilenames(**self._kwargs)
def _savefilename(self):
""" Get a save file location. """
logger.debug("Popping SaveFilename browser")
return filedialog.asksaveasfilename(**self._kwargs)
@staticmethod
def _nothing(): # pylint: disable=useless-return
""" Method that does nothing, used for disabling open/save pop up. """
logger.debug("Popping Nothing browser")
return
class Images():
""" The centralized image repository for holding all icons and images required by the GUI.
This class should be initialized on GUI startup through :func:`initialize_images`. Any further
access to this class should be through :func:`get_images`.
"""
def __init__(self):
logger.debug("Initializing %s", self.__class__.__name__)
self._pathpreview = os.path.join(PATHCACHE, "preview")
self._pathoutput = None
self._previewoutput = None
self._previewtrain = dict()
self._previewcache = dict(modified=None, # cache for extract and convert
images=None,
filenames=list(),
placeholder=None)
self._errcount = 0
self._icons = self._load_icons()
logger.debug("Initialized %s", self.__class__.__name__)
@property
def previewoutput(self):
""" Tuple or ``None``: First item in the tuple is the extract or convert preview image
(:class:`PIL.Image`), the second item is the image in a format that tkinter can display
(:class:`PIL.ImageTK.PhotoImage`).
The value of the property is ``None`` if no extract or convert task is running or there are
no files available in the output folder. """
return self._previewoutput
@property
def previewtrain(self):
""" dict or ``None``: The training preview images. Dictionary key is the image name
(`str`). Dictionary values are a `list` of the training image (:class:`PIL.Image`), the
image formatted for tkinter display (:class:`PIL.ImageTK.PhotoImage`), the last
modification time of the image (`float`).
The value of this property is ``None`` if training is not running or there are no preview
images available.
"""
return self._previewtrain
@property
def icons(self):
""" dict: The faceswap icons for all parts of the GUI. The dictionary key is the icon
name (`str`) the value is the icon sized and formatted for display
(:class:`PIL.ImageTK.PhotoImage`).
Example
-------
>>> icons = get_images().icons
>>> save = icons["save"]
>>> button = ttk.Button(parent, image=save)
>>> button.pack()
"""
return self._icons
@staticmethod
def _load_icons():
""" Scan the icons cache folder and load the icons into :attr:`icons` for retrieval
throughout the GUI.
Returns
-------
dict:
The icons formatted as described in :attr:`icons`
"""
size = get_config().user_config_dict.get("icon_size", 16)
size = int(round(size * get_config().scaling_factor))
icons = dict()
pathicons = os.path.join(PATHCACHE, "icons")
for fname in os.listdir(pathicons):
name, ext = os.path.splitext(fname)
if ext != ".png":
continue
img = Image.open(os.path.join(pathicons, fname))
img = ImageTk.PhotoImage(img.resize((size, size), resample=Image.HAMMING))
icons[name] = img
logger.debug(icons)
return icons
def set_faceswap_output_path(self, location):
""" Set the path that will contain the output from an Extract or Convert task.
Required so that the GUI can fetch output images to display for return in
:attr:`previewoutput`.
Parameters
----------
location: str
The output location that has been specified for an Extract or Convert task
"""
self._pathoutput = location
def delete_preview(self):
""" Delete the preview files in the cache folder and reset the image cache.
Should be called when terminating tasks, or when Faceswap starts up or shuts down.
"""
logger.debug("Deleting previews")
for item in os.listdir(self._pathpreview):
if item.startswith(".gui_training_preview") and item.endswith(".jpg"):
fullitem = os.path.join(self._pathpreview, item)
logger.debug("Deleting: '%s'", fullitem)
os.remove(fullitem)
for fname in self._previewcache["filenames"]:
if os.path.basename(fname) == ".gui_preview.jpg":
logger.debug("Deleting: '%s'", fname)
try:
os.remove(fname)
except FileNotFoundError:
logger.debug("File does not exist: %s", fname)
self._clear_image_cache()
def _clear_image_cache(self):
""" Clear all cached images. """
logger.debug("Clearing image cache")
self._pathoutput = None
self._previewoutput = None
self._previewtrain = dict()
self._previewcache = dict(modified=None, # cache for extract and convert
images=None,
filenames=list(),
placeholder=None)
@staticmethod
def _get_images(image_path):
""" Get the images stored within the given directory.
Parameters
----------
image_path: str
The folder containing images to be scanned
Returns
-------
list:
The image filenames stored within the given folder
"""
logger.debug("Getting images: '%s'", image_path)
if not os.path.isdir(image_path):
logger.debug("Folder does not exist")
return None
files = [os.path.join(image_path, f)
for f in os.listdir(image_path) if f.lower().endswith((".png", ".jpg"))]
logger.debug("Image files: %s", files)
return files
def load_latest_preview(self, thumbnail_size, frame_dims):
""" Load the latest preview image for extract and convert.
Retrieves the latest preview images from the faceswap output folder, resizes to thumbnails
and lays out for display. Places the images into :attr:`previewoutput` for loading into
the display panel.
Parameters
----------
thumbnail_size: int
The size of each thumbnail that should be created
frame_dims: tuple
The (width (`int`), height (`int`)) of the display panel that will display the preview
"""
logger.debug("Loading preview image: (thumbnail_size: %s, frame_dims: %s)",
thumbnail_size, frame_dims)
image_files = self._get_images(self._pathoutput)
gui_preview = os.path.join(self._pathoutput, ".gui_preview.jpg")
if not image_files or (len(image_files) == 1 and gui_preview not in image_files):
logger.debug("No preview to display")
return
# Filter to just the gui_preview if it exists in folder output
image_files = [gui_preview] if gui_preview in image_files else image_files
logger.debug("Image Files: %s", len(image_files))
image_files = self._get_newest_filenames(image_files)
if not image_files:
return
self._load_images_to_cache(image_files, frame_dims, thumbnail_size)
if image_files == [gui_preview]:
# Delete the preview image so that the main scripts know to output another
logger.debug("Deleting preview image")
os.remove(image_files[0])
show_image = self._place_previews(frame_dims)
if not show_image:
self._previewoutput = None
return
logger.debug("Displaying preview: %s", self._previewcache["filenames"])
self._previewoutput = (show_image, ImageTk.PhotoImage(show_image))
def _get_newest_filenames(self, image_files):
""" Return image filenames that have been modified since the last check.
Parameters
----------
image_files: list
The list of image files to check the modification date for
Returns
-------
list:
A list of images that have been modified since the last check
"""
if self._previewcache["modified"] is None:
retval = image_files
else:
retval = [fname for fname in image_files
if os.path.getmtime(fname) > self._previewcache["modified"]]
if not retval:
logger.debug("No new images in output folder")
else:
self._previewcache["modified"] = max([os.path.getmtime(img) for img in retval])
logger.debug("Number new images: %s, Last Modified: %s",
len(retval), self._previewcache["modified"])
return retval
def _load_images_to_cache(self, image_files, frame_dims, thumbnail_size):
""" Load preview images to the image cache.
Load new images and append to cache, filtering the cache the number of thumbnails that will
fit inside the display panel.
Parameters
----------
image_files: list
A list of new image files that have been modified since the last check
frame_dims: tuple
The (width (`int`), height (`int`)) of the display panel that will display the preview
thumbnail_size: int
The size of each thumbnail that should be created
"""
logger.debug("Number image_files: %s, frame_dims: %s, thumbnail_size: %s",
len(image_files), frame_dims, thumbnail_size)
num_images = (frame_dims[0] // thumbnail_size) * (frame_dims[1] // thumbnail_size)
logger.debug("num_images: %s", num_images)
if num_images == 0:
return
samples = list()
start_idx = len(image_files) - num_images if len(image_files) > num_images else 0
show_files = sorted(image_files, key=os.path.getctime)[start_idx:]
for fname in show_files:
img = Image.open(fname)
width, height = img.size
scaling = thumbnail_size / max(width, height)
logger.debug("image width: %s, height: %s, scaling: %s", width, height, scaling)
img = img.resize((int(width * scaling), int(height * scaling)))
if img.size[0] != img.size[1]:
# Pad to square
new_img = Image.new("RGB", (thumbnail_size, thumbnail_size))
new_img.paste(img, ((thumbnail_size - img.size[0])//2,
(thumbnail_size - img.size[1])//2))
img = new_img
draw = ImageDraw.Draw(img)
draw.rectangle(((0, 0), (thumbnail_size, thumbnail_size)), outline="#E5E5E5", width=1)
samples.append(np.array(img))
samples = np.array(samples)
self._previewcache["filenames"] = (self._previewcache["filenames"] +
show_files)[-num_images:]
cache = self._previewcache["images"]
if cache is None:
logger.debug("Creating new cache")
cache = samples[-num_images:]
else:
logger.debug("Appending to existing cache")
cache = np.concatenate((cache, samples))[-num_images:]
self._previewcache["images"] = cache
logger.debug("Cache shape: %s", self._previewcache["images"].shape)
def _place_previews(self, frame_dims):
""" Format the preview thumbnails stored in the cache into a grid fitting the display
panel.
Parameters
----------
frame_dims: tuple
The (width (`int`), height (`int`)) of the display panel that will display the preview
Returns
:class:`PIL.Image`:
The final preview display image
"""
if self._previewcache.get("images", None) is None:
logger.debug("No images in cache. Returning None")
return None
samples = self._previewcache["images"].copy()
num_images, thumbnail_size = samples.shape[:2]
if self._previewcache["placeholder"] is None:
self._create_placeholder(thumbnail_size)
logger.debug("num_images: %s, thumbnail_size: %s", num_images, thumbnail_size)
cols, rows = frame_dims[0] // thumbnail_size, frame_dims[1] // thumbnail_size
logger.debug("cols: %s, rows: %s", cols, rows)
if cols == 0 or rows == 0:
logger.debug("Cols or Rows is zero. No items to display")
return None
remainder = (cols * rows) - num_images
if remainder != 0:
logger.debug("Padding sample display. Remainder: %s", remainder)
placeholder = np.concatenate([np.expand_dims(self._previewcache["placeholder"],
0)] * remainder)
samples = np.concatenate((samples, placeholder))
display = np.vstack([np.hstack(samples[row * cols: (row + 1) * cols])
for row in range(rows)])
logger.debug("display shape: %s", display.shape)
return Image.fromarray(display)
def _create_placeholder(self, thumbnail_size):
""" Create a placeholder image for when there are fewer thumbnails available
than columns to display them.
Parameters
----------
thumbnail_size: int
The size of the thumbnail that the placeholder should replicate
"""
logger.debug("Creating placeholder. thumbnail_size: %s", thumbnail_size)
placeholder = Image.new("RGB", (thumbnail_size, thumbnail_size))
draw = ImageDraw.Draw(placeholder)
draw.rectangle(((0, 0), (thumbnail_size, thumbnail_size)), outline="#E5E5E5", width=1)
placeholder = np.array(placeholder)
self._previewcache["placeholder"] = placeholder
logger.debug("Created placeholder. shape: %s", placeholder.shape)
def load_training_preview(self):
""" Load the training preview images.
Reads the training image currently stored in the cache folder and loads them to
:attr:`previewtrain` for retrieval in the GUI.
"""
logger.debug("Loading Training preview images")
image_files = self._get_images(self._pathpreview)
modified = None
if not image_files:
logger.debug("No preview to display")
self._previewtrain = dict()
return
for img in image_files:
modified = os.path.getmtime(img) if modified is None else modified
name = os.path.basename(img)
name = os.path.splitext(name)[0]
name = name[name.rfind("_") + 1:].title()
try:
logger.debug("Displaying preview: '%s'", img)
size = self._get_current_size(name)
self._previewtrain[name] = [Image.open(img), None, modified]
self.resize_image(name, size)
self._errcount = 0
except ValueError:
# This is probably an error reading the file whilst it's
# being saved so ignore it for now and only pick up if
# there have been multiple consecutive fails
logger.warning("Unable to display preview: (image: '%s', attempt: %s)",
img, self._errcount)
if self._errcount < 10:
self._errcount += 1
else:
logger.error("Error reading the preview file for '%s'", img)
print("Error reading the preview file for {}".format(name))
self._previewtrain[name] = None
def _get_current_size(self, name):
""" Return the size of the currently displayed training preview image.
Parameters
----------
name: str
The name of the training image to get the size for
Returns
-------
width: int
The width of the training image
height: int
The height of the training image
"""
logger.debug("Getting size: '%s'", name)
if not self._previewtrain.get(name, None):
return None
img = self._previewtrain[name][1]
if not img:
return None
logger.debug("Got size: (name: '%s', width: '%s', height: '%s')",
name, img.width(), img.height())
return img.width(), img.height()
def resize_image(self, name, frame_dims):
""" Resize the training preview image based on the passed in frame size.
If the canvas that holds the preview image changes, update the image size
to fit the new canvas and refresh :attr:`previewtrain`.
Parameters
----------
name: str
The name of the training image to be resized
frame_dims: tuple
The (width (`int`), height (`int`)) of the display panel that will display the preview
"""
logger.debug("Resizing image: (name: '%s', frame_dims: %s", name, frame_dims)
displayimg = self._previewtrain[name][0]
if frame_dims:
frameratio = float(frame_dims[0]) / float(frame_dims[1])
imgratio = float(displayimg.size[0]) / float(displayimg.size[1])
if frameratio <= imgratio:
scale = frame_dims[0] / float(displayimg.size[0])
size = (frame_dims[0], int(displayimg.size[1] * scale))
else:
scale = frame_dims[1] / float(displayimg.size[1])
size = (int(displayimg.size[0] * scale), frame_dims[1])
logger.debug("Scaling: (scale: %s, size: %s", scale, size)
# Hacky fix to force a reload if it happens to find corrupted
# data, probably due to reading the image whilst it is partially
# saved. If it continues to fail, then eventually raise.
for i in range(0, 1000):
try:
displayimg = displayimg.resize(size, Image.ANTIALIAS)
except OSError:
if i == 999:
raise
continue
break
self._previewtrain[name][1] = ImageTk.PhotoImage(displayimg)
class Config():
""" The centralized configuration class for holding items that should be made available to all
parts of the GUI.
This class should be initialized on GUI startup through :func:`initialize_config`. Any further
access to this class should be through :func:`get_config`.
Parameters
----------
root: :class:`tkinter.Tk`
The root Tkinter object
cli_opts: :class:`lib.gui.options.CliOpts`
The command line options object
statusbar: :class:`lib.gui.custom_widgets.StatusBar`
The GUI Status bar
"""
def __init__(self, root, cli_opts, statusbar):
logger.debug("Initializing %s: (root %s, cli_opts: %s, statusbar: %s)",
self.__class__.__name__, root, cli_opts, statusbar)
self._default_font = tk.font.nametofont("TkDefaultFont").configure()["family"]
self._constants = dict(
root=root,
scaling_factor=self._get_scaling(root),
default_font=self._default_font)
self._gui_objects = dict(
cli_opts=cli_opts,
tk_vars=self._set_tk_vars(),
project=Project(self, FileHandler),
tasks=Tasks(self, FileHandler),
default_options=None,
status_bar=statusbar,
command_notebook=None) # set in command.py
self._user_config = UserConfig(None)
logger.debug("Initialized %s", self.__class__.__name__)
# Constants
@property
def root(self):
""" :class:`tkinter.Tk`: The root tkinter window. """
return self._constants["root"]
@property
def scaling_factor(self):
""" float: The scaling factor for current display. """
return self._constants["scaling_factor"]
@property
def pathcache(self):
""" str: The path to the GUI cache folder """
return PATHCACHE
# GUI Objects
@property
def cli_opts(self):
""" :class:`lib.gui.options.CliOptions`: The command line options for this GUI Session. """
return self._gui_objects["cli_opts"]
@property
def tk_vars(self):
""" dict: The global tkinter variables. """
return self._gui_objects["tk_vars"]
@property
def project(self):
""" :class:`lib.gui.project.Project`: The project session handler. """
return self._gui_objects["project"]
@property
def tasks(self):
""" :class:`lib.gui.project.Tasks`: The session tasks handler. """
return self._gui_objects["tasks"]
@property
def default_options(self):
""" dict: The default options for all tabs """
return self._gui_objects["default_options"]
@property
def statusbar(self):
""" :class:`lib.gui.custom_widgets.StatusBar`: The GUI StatusBar
:class:`tkinter.ttk.Frame`. """
return self._gui_objects["status_bar"]
@property
def command_notebook(self):
""" :class:`lib.gui.command.CommandNoteboook`: The main Faceswap Command Notebook. """
return self._gui_objects["command_notebook"]
# Convenience GUI Objects
@property
def tools_notebook(self):
""" :class:`lib.gui.command.ToolsNotebook`: The Faceswap Tools sub-Notebook. """
return self.command_notebook.tools_notebook
@property
def modified_vars(self):
""" dict: The command notebook modified tkinter variables. """
return self.command_notebook.modified_vars
@property
def _command_tabs(self):
""" dict: Command tab titles with their IDs. """
return self.command_notebook.tab_names
@property
def _tools_tabs(self):
""" dict: Tools command tab titles with their IDs. """
return self.command_notebook.tools_tab_names
# Config
@property
def user_config(self):
""" dict: The GUI config in dict form. """
return self._user_config
@property
def user_config_dict(self):
""" dict: The GUI config in dict form. """
return self._user_config.config_dict
@property
def default_font(self):
""" tuple: The selected font as configured in user settings. First item is the font (`str`)
second item the font size (`int`). """
font = self.user_config_dict["font"]
font = self._default_font if font == "default" else font
return (font, self.user_config_dict["font_size"])
@staticmethod
def _get_scaling(root):
""" Get the display DPI.
Returns
-------
float:
The scaling factor
"""
dpi = root.winfo_fpixels("1i")
scaling = dpi / 72.0
logger.debug("dpi: %s, scaling: %s'", dpi, scaling)
return scaling
def set_default_options(self):
""" Set the default options for :mod:`lib.gui.projects`
The Default GUI options are stored on Faceswap startup.
Exposed as the :attr:`_default_opts` for a project cannot be set until after the main
Command Tabs have been loaded.
"""
default = self.cli_opts.get_option_values()
logger.debug(default)
self._gui_objects["default_options"] = default
self.project.set_default_options()
def set_command_notebook(self, notebook):
""" Set the command notebook to the :attr:`command_notebook` attribute
and enable the modified callback for :attr:`project`.
Parameters
----------
notebook: :class:`lib.gui.command.CommandNotebook`
The main command notebook for the Faceswap GUI
"""
logger.debug("Setting commane notebook: %s", notebook)
self._gui_objects["command_notebook"] = notebook
self.project.set_modified_callback()
def set_active_tab_by_name(self, name):
""" Sets the :attr:`command_notebook` or :attr:`tools_notebook` to active based on given
name.
Parameters
----------
name: str
The name of the tab to set active
"""
name = name.lower()
if name in self._command_tabs:
tab_id = self._command_tabs[name]
logger.debug("Setting active tab to: (name: %s, id: %s)", name, tab_id)
self.command_notebook.select(tab_id)
elif name in self._tools_tabs:
self.command_notebook.select(self._command_tabs["tools"])
tab_id = self._tools_tabs[name]
logger.debug("Setting active Tools tab to: (name: %s, id: %s)", name, tab_id)
self.tools_notebook.select()
else:
logger.debug("Name couldn't be found. Setting to id 0: %s", name)
self.command_notebook.select(0)
def set_modified_true(self, command):
""" Set the modified variable to ``True`` for the given command in :attr:`modified_vars`.
Parameters
----------
command: str
The command to set the modified state to ``True``
"""
tkvar = self.modified_vars.get(command, None)
if tkvar is None:
logger.debug("No tkvar for command: '%s'", command)
return
tkvar.set(True)
logger.debug("Set modified var to True for: '%s'", command)
def refresh_config(self):
""" Reload the user config from file. """
self._user_config = UserConfig(None)
def set_cursor_busy(self, widget=None):
""" Set the root or widget cursor to busy.
Parameters
----------
widget: tkinter object, optional
The widget to set busy cursor for. If the provided value is ``None`` then sets the
cursor busy for the whole of the GUI. Default: ``None``.
"""
logger.debug("Setting cursor to busy. widget: %s", widget)
widget = self.root if widget is None else widget
widget.config(cursor="watch")
widget.update_idletasks()
def set_cursor_default(self, widget=None):
""" Set the root or widget cursor to default.
Parameters
----------
widget: tkinter object, optional
The widget to set default cursor for. If the provided value is ``None`` then sets the
cursor busy for the whole of the GUI. Default: ``None``
"""
logger.debug("Setting cursor to default. widget: %s", widget)
widget = self.root if widget is None else widget
widget.config(cursor="")
widget.update_idletasks()
@staticmethod
def _set_tk_vars():
""" Set the global tkinter variables stored for easy access in :class:`Config`.
The variables are available through :attr:`tk_vars`.
"""
display = tk.StringVar()
display.set(None)
runningtask = tk.BooleanVar()
runningtask.set(False)
istraining = tk.BooleanVar()
istraining.set(False)
actioncommand = tk.StringVar()
actioncommand.set(None)
generatecommand = tk.StringVar()
generatecommand.set(None)
consoleclear = tk.BooleanVar()
consoleclear.set(False)
refreshgraph = tk.BooleanVar()
refreshgraph.set(False)
updatepreview = tk.BooleanVar()
updatepreview.set(False)
analysis_folder = tk.StringVar()
analysis_folder.set(None)
tk_vars = {"display": display,
"runningtask": runningtask,
"istraining": istraining,
"action": actioncommand,
"generate": generatecommand,
"consoleclear": consoleclear,
"refreshgraph": refreshgraph,
"updatepreview": updatepreview,
"analysis_folder": analysis_folder}
logger.debug(tk_vars)
return tk_vars
def set_root_title(self, text=None):
""" Set the main title text for Faceswap.
The title will always begin with 'Faceswap.py'. Additional text can be appended.
Parameters
----------
text: str, optional
Additional text to be appended to the GUI title bar. Default: ``None``
"""
title = "Faceswap.py"
title += " - {}".format(text) if text is not None and text else ""
self.root.title(title)
def set_geometry(self, width, height, fullscreen=False):
""" Set the geometry for the root tkinter object.
Parameters
----------
width: int
The width to set the window to (prior to scaling)
height: int
The height to set the window to (prior to scaling)
fullscreen: bool, optional
Whether to set the window to full-screen mode. If ``True`` then :attr:`width` and
:attr:`height` are ignored. Default: ``False``
"""
self.root.tk.call("tk", "scaling", self.scaling_factor)
if fullscreen:
initial_dimensions = (self.root.winfo_screenwidth(), self.root.winfo_screenheight())
else:
initial_dimensions = (round(width * self.scaling_factor),
round(height * self.scaling_factor))
if fullscreen and sys.platform in ("win32", "darwin"):
self.root.state('zoomed')
elif fullscreen:
self.root.attributes('-zoomed', True)
else:
self.root.geometry("{}x{}+80+80".format(str(initial_dimensions[0]),
str(initial_dimensions[1])))
logger.debug("Geometry: %sx%s", *initial_dimensions)
class LongRunningTask(Thread):
""" Runs long running tasks in a background thread to prevent the GUI from becoming
unresponsive.
This is sub-classed from :class:`Threading.Thread` so check documentation there for base
parameters. Additional parameters listed below.
Parameters
----------
widget: tkinter object, optional
The widget that this :class:`LongRunningTask` is associated with. Used for setting the busy
cursor in the correct location. Default: ``None``.
"""
def __init__(self, group=None, target=None, name=None, args=(), kwargs=None, *, daemon=True,
widget=None):
logger.debug("Initializing %s: (group: %s, target: %s, name: %s, args: %s, kwargs: %s, "
"daemon: %s)", self.__class__.__name__, group, target, name, args, kwargs,
daemon)
super().__init__(group=group, target=target, name=name, args=args, kwargs=kwargs,
daemon=daemon)
self.err = None
self._widget = widget
self._config = get_config()
self._config.set_cursor_busy(widget=self._widget)
self._complete = Event()
self._queue = Queue()
logger.debug("Initialized %s", self.__class__.__name__,)
@property
def complete(self):
""" :class:`threading.Event`: Event is set if the thread has completed its task,
otherwise it is unset.
"""
return self._complete
def run(self):
""" Commence the given task in a background thread. """
try:
if self._target:
retval = self._target(*self._args, **self._kwargs)
self._queue.put(retval)
except Exception: # pylint: disable=broad-except
self.err = sys.exc_info()
logger.debug("Error in thread (%s): %s", self._name,
self.err[1].with_traceback(self.err[2]))
finally:
self._complete.set()
# Avoid a ref-cycle if the thread is running a function with
# an argument that has a member that points to the thread.
del self._target, self._args, self._kwargs
def get_result(self):
""" Return the result from the given task.
Returns
-------
varies:
The result of the thread will depend on the given task. If a call is made to
:func:`get_result` prior to the thread completing its task then ``None`` will be
returned
"""
if not self._complete.is_set():
logger.warning("Aborting attempt to retrieve result from a LongRunningTask that is "
"still running")
return None
if self.err:
logger.debug("Error caught in thread")
self._config.set_cursor_default(widget=self._widget)
raise self.err[1].with_traceback(self.err[2])
logger.debug("Getting result from thread")
retval = self._queue.get()
logger.debug("Got result from thread")
self._config.set_cursor_default(widget=self._widget)
return retval
class PreviewTrigger():
""" Trigger to indicate to underlying Faceswap process that the preview image should
be updated.
Writes a file to the cache folder that is picked up by the main process.
"""
def __init__(self):
logger.debug("Initializing: %s", self.__class__.__name__)
self._trigger_file = os.path.join(PATHCACHE, ".preview_trigger")
logger.debug("Initialized: %s (trigger_file: %s)",
self.__class__.__name__, self._trigger_file)
def set(self):
""" Place the trigger file into the cache folder """
if not os.path.isfile(self._trigger_file):
with open(self._trigger_file, "w"):
pass
logger.debug("Set preview update trigger: %s", self._trigger_file)
def clear(self):
""" Remove the trigger file from the cache folder """
if os.path.isfile(self._trigger_file):
os.remove(self._trigger_file)
logger.debug("Removed preview update trigger: %s", self._trigger_file)
def preview_trigger():
""" Set the global preview trigger if it has not always been set and return.
Returns
-------
:class:`PreviewTrigger`
The trigger to indicate to the main faceswap process that it should perform a training
preview update
"""
global _PREVIEW_TRIGGER # pylint:disable=global-statement
if _PREVIEW_TRIGGER is None:
_PREVIEW_TRIGGER = PreviewTrigger()
return _PREVIEW_TRIGGER