mirror of
https://github.com/zebrajr/faceswap.git
synced 2025-12-06 12:20:27 +01:00
1199 lines
46 KiB
Python
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
|