1
0
Fork 0
mirror of https://github.com/deepfakes/faceswap synced 2025-06-07 10:43:27 -04:00
faceswap/lib/gui/utils/image.py
torzdf 6a3b674bef
Rebase code (#1326)
* Remove tensorflow_probability requirement

* setup.py - fix progress bars

* requirements.txt: Remove pre python 3.9 packages

* update apple requirements.txt

* update INSTALL.md

* Remove python<3.9 code

* setup.py - fix Windows Installer

* typing: python3.9 compliant

* Update pytest and readthedocs python versions

* typing fixes

* Python Version updates
  - Reduce max version to 3.10
  - Default to 3.10 in installers
  - Remove incompatible 3.11 tests

* Update dependencies

* Downgrade imageio dep for Windows

* typing: merge optional unions and fixes

* Updates
  - min python version 3.10
  - typing to python 3.10 spec
  - remove pre-tf2.10 code
  - Add conda tests

* train: re-enable optimizer saving

* Update dockerfiles

* Update setup.py
  - Apple Conda deps to setup.py
  - Better Cuda + dependency handling

* bugfix: Patch logging to prevent Autograph errors

* Update dockerfiles

* Setup.py - Setup.py - stdout to utf-8

* Add more OSes to github Actions

* suppress mac-os end to end test
2023-06-27 11:27:47 +01:00

659 lines
25 KiB
Python

#!/usr/bin python3
""" Utilities for handling images in the Faceswap GUI """
from __future__ import annotations
import logging
import os
import typing as T
import cv2
import numpy as np
from PIL import Image, ImageDraw, ImageTk
from lib.training.preview_cv import PreviewBuffer
from .config import get_config, PATHCACHE
if T.TYPE_CHECKING:
from collections.abc import Sequence
logger = logging.getLogger(__name__) # pylint: disable=invalid-name
_IMAGES: "Images" | None = None
_PREVIEW_TRIGGER: "PreviewTrigger" | None = None
TRAININGPREVIEW = ".gui_training_preview.png"
def initialize_images() -> None:
""" 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() -> "Images":
""" Get the Master GUI Images handler.
Returns
-------
:class:`Images`
The Master GUI Images handler
"""
assert _IMAGES is not None
return _IMAGES
def _get_previews(image_path: str) -> list[str]:
""" 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 []
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
class PreviewTrain():
""" Handles the loading of the training preview image(s) and adding to the display buffer
Parameters
----------
cache_path: str
Full path to the cache folder that contains the preview images
"""
def __init__(self, cache_path: str) -> None:
logger.debug("Initializing %s: (cache_path: '%s')", self.__class__.__name__, cache_path)
self._buffer = PreviewBuffer()
self._cache_path = cache_path
self._modified: float = 0.0
self._error_count: int = 0
logger.debug("Initialized %s", self.__class__.__name__)
@property
def buffer(self) -> PreviewBuffer:
""" :class:`~lib.training.PreviewBuffer` The preview buffer for the training preview
image. """
return self._buffer
def load(self) -> bool:
""" Load the latest training preview image(s) from disk and add to :attr:`buffer` """
logger.trace("Loading Training preview images") # type:ignore
image_files = _get_previews(self._cache_path)
filename = next((fname for fname in image_files
if os.path.basename(fname) == TRAININGPREVIEW), "")
if not filename:
logger.trace("No preview to display") # type:ignore
return False
try:
modified = os.path.getmtime(filename)
if modified <= self._modified:
logger.trace("preview '%s' not updated. Current timestamp: %s, " # type:ignore
"existing timestamp: %s", filename, modified, self._modified)
return False
logger.debug("Loading preview: '%s'", filename)
img = cv2.imread(filename, cv2.IMREAD_UNCHANGED)
assert img is not None
self._modified = modified
self._buffer.add_image(os.path.basename(filename), img)
self._error_count = 0
except (ValueError, AssertionError):
# 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.debug("Unable to display preview: (image: '%s', attempt: %s)",
img, self._error_count)
if self._error_count < 10:
self._error_count += 1
else:
logger.error("Error reading the preview file for '%s'", filename)
return False
logger.debug("Loaded preview: '%s' (%s)", filename, img.shape)
return True
def reset(self) -> None:
""" Reset the preview buffer when the display page has been disabled.
Notes
-----
The buffer requires resetting, otherwise the re-enabled preview window hangs waiting for a
training image that has already been marked as processed
"""
logger.debug("Resetting training preview")
del self._buffer
self._buffer = PreviewBuffer()
self._modified = 0.0
self._error_count = 0
class PreviewExtract():
""" Handles the loading of preview images for extract and convert
Parameters
----------
cache_path: str
Full path to the cache folder that contains the preview images
"""
def __init__(self, cache_path: str) -> None:
logger.debug("Initializing %s: (cache_path: '%s')", self.__class__.__name__, cache_path)
self._cache_path = cache_path
self._batch_mode = False
self._output_path = ""
self._modified: float = 0.0
self._filenames: list[str] = []
self._images: np.ndarray | None = None
self._placeholder: np.ndarray | None = None
self._preview_image: Image.Image | None = None
self._preview_image_tk: ImageTk.PhotoImage | None = None
logger.debug("Initialized %s", self.__class__.__name__)
@property
def image(self) -> ImageTk.PhotoImage:
""":class:`PIL.ImageTk.PhotoImage` The preview image for displaying in a tkinter canvas """
assert self._preview_image_tk is not None
return self._preview_image_tk
def save(self, filename: str) -> None:
""" Save the currently displaying preview image to the given location
Parameters
----------
filename: str
The full path to the filename to save the preview image to
"""
logger.debug("Saving preview to %s", filename)
assert self._preview_image is not None
self._preview_image.save(filename)
def set_faceswap_output_path(self, location: str, batch_mode: bool = False) -> None:
""" 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:`preview_image`.
Parameters
----------
location: str
The output location that has been specified for an Extract or Convert task
batch_mode: bool
``True`` if extracting in batch mode otherwise False
"""
self._output_path = location
self._batch_mode = batch_mode
def _get_newest_folder(self) -> str:
""" Obtain the most recent folder created in the extraction output folder when processing
in batch mode.
Returns
-------
str
The most recently modified folder within the parent output folder. If no folders have
been created, returns the parent output folder
"""
folders = [] if not os.path.exists(self._output_path) else [
os.path.join(self._output_path, folder)
for folder in os.listdir(self._output_path)
if os.path.isdir(os.path.join(self._output_path, folder))]
folders.sort(key=os.path.getmtime)
retval = folders[-1] if folders else self._output_path
logger.debug("sorted folders: %s, return value: %s", folders, retval)
return retval
def _get_newest_filenames(self, image_files: list[str]) -> list[str]:
""" 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 not self._modified:
retval = image_files
else:
retval = [fname for fname in image_files
if os.path.getmtime(fname) > self._modified]
if not retval:
logger.debug("No new images in output folder")
else:
self._modified = max(os.path.getmtime(img) for img in retval)
logger.debug("Number new images: %s, Last Modified: %s",
len(retval), self._modified)
return retval
def _pad_and_border(self, image: Image.Image, size: int) -> np.ndarray:
""" Pad rectangle images to a square and draw borders
Parameters
----------
image: :class:`PIL.Image`
The image to process
size: int
The size of the image as it should be displayed
Returns
-------
:class:`numpy.ndarray`:
The processed image
"""
if image.size[0] != image.size[1]:
# Pad to square
new_img = Image.new("RGB", (size, size))
new_img.paste(image, ((size - image.size[0]) // 2, (size - image.size[1]) // 2))
image = new_img
draw = ImageDraw.Draw(image)
draw.rectangle(((0, 0), (size, size)), outline="#E5E5E5", width=1)
retval = np.array(image)
logger.trace("image shape: %s", retval.shape) # type: ignore
return retval
def _process_samples(self,
samples: list[np.ndarray],
filenames: list[str],
num_images: int) -> bool:
""" Process the latest sample images into a displayable image.
Parameters
----------
samples: list
The list of extract/convert preview images to display
filenames: list
The full path to the filenames corresponding to the images
num_images: int
The number of images that should be displayed
Returns
-------
bool
``True`` if samples succesfully compiled otherwise ``False``
"""
asamples = np.array(samples)
if not np.any(asamples):
logger.debug("No preview images collected.")
return False
self._filenames = (self._filenames + filenames)[-num_images:]
cache = self._images
if cache is None:
logger.debug("Creating new cache")
cache = asamples[-num_images:]
else:
logger.debug("Appending to existing cache")
cache = np.concatenate((cache, asamples))[-num_images:]
self._images = cache
assert self._images is not None
logger.debug("Cache shape: %s", self._images.shape)
return True
def _load_images_to_cache(self,
image_files: list[str],
frame_dims: tuple[int, int],
thumbnail_size: int) -> bool:
""" Load preview images to the image cache.
Load new images and append to cache, filtering the cache to 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
Returns
-------
bool
``True`` if images were successfully loaded to cache otherwise ``False``
"""
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 False
samples: list[np.ndarray] = []
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:]
dropped_files = []
for fname in show_files:
try:
img = Image.open(fname)
except PermissionError as err:
logger.debug("Permission error opening preview file: '%s'. Original error: %s",
fname, str(err))
dropped_files.append(fname)
continue
except Exception as err: # pylint:disable=broad-except
# Swallow any issues with opening an image rather than spamming console
# Can happen when trying to read partially saved images
logger.debug("Error opening preview file: '%s'. Original error: %s",
fname, str(err))
dropped_files.append(fname)
continue
width, height = img.size
scaling = thumbnail_size / max(width, height)
logger.debug("image width: %s, height: %s, scaling: %s", width, height, scaling)
try:
img = img.resize((int(width * scaling), int(height * scaling)))
except OSError as err:
# Image only gets loaded when we call a method, so may error on partial loads
logger.debug("OS Error resizing preview image: '%s'. Original error: %s",
fname, err)
dropped_files.append(fname)
continue
samples.append(self._pad_and_border(img, thumbnail_size))
return self._process_samples(samples,
[fname for fname in show_files if fname not in dropped_files],
num_images)
def _create_placeholder(self, thumbnail_size: int) -> None:
""" 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._placeholder = placeholder
logger.debug("Created placeholder. shape: %s", placeholder.shape)
def _place_previews(self, frame_dims: tuple[int, int]) -> Image.Image:
""" 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._images is None:
logger.debug("No images in cache. Returning None")
return None
samples = self._images.copy()
num_images, thumbnail_size = samples.shape[:2]
if self._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)
assert self._placeholder is not None
placeholder = np.concatenate([np.expand_dims(self._placeholder, 0)] * remainder)
samples = np.concatenate((samples, placeholder))
display = np.vstack([np.hstack(T.cast("Sequence", samples[row * cols: (row + 1) * cols]))
for row in range(rows)])
logger.debug("display shape: %s", display.shape)
return Image.fromarray(display)
def load_latest_preview(self, thumbnail_size: int, frame_dims: tuple[int, int]) -> bool:
""" 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:`preview_image` 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
Returns
-------
bool
``True`` if a preview was succesfully loaded otherwise ``False``
"""
logger.debug("Loading preview image: (thumbnail_size: %s, frame_dims: %s)",
thumbnail_size, frame_dims)
image_path = self._get_newest_folder() if self._batch_mode else self._output_path
image_files = _get_previews(image_path)
gui_preview = os.path.join(self._output_path, ".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 False
# 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 False
if not self._load_images_to_cache(image_files, frame_dims, thumbnail_size):
logger.debug("Failed to load any preview images")
if gui_preview in image_files:
# Reset last modified for failed loading of a gui preview image so it is picked
# up next time
self._modified = 0.0
return False
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._preview_image = None
self._preview_image_tk = None
return False
logger.debug("Displaying preview: %s", self._filenames)
self._preview_image = show_image
self._preview_image_tk = ImageTk.PhotoImage(show_image)
return True
def delete_previews(self) -> None:
""" Remove any image preview files """
for fname in self._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)
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) -> None:
logger.debug("Initializing %s", self.__class__.__name__)
self._pathpreview = os.path.join(PATHCACHE, "preview")
self._pathoutput: str | None = None
self._batch_mode = False
self._preview_train = PreviewTrain(self._pathpreview)
self._preview_extract = PreviewExtract(self._pathpreview)
self._icons = self._load_icons()
logger.debug("Initialized %s", self.__class__.__name__)
@property
def preview_train(self) -> PreviewTrain:
""" :class:`PreviewTrain` The object handling the training preview images """
return self._preview_train
@property
def preview_extract(self) -> PreviewExtract:
""" :class:`PreviewTrain` The object handling the training preview images """
return self._preview_extract
@property
def icons(self) -> dict[str, ImageTk.PhotoImage]:
""" 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() -> dict[str, ImageTk.PhotoImage]:
""" 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[str, ImageTk.PhotoImage] = {}
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 delete_preview(self) -> None:
""" 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(os.path.splitext(TRAININGPREVIEW)[0]) and item.endswith((".jpg",
".png")):
fullitem = os.path.join(self._pathpreview, item)
logger.debug("Deleting: '%s'", fullitem)
os.remove(fullitem)
self._preview_extract.delete_previews()
del self._preview_train
del self._preview_extract
self._preview_train = PreviewTrain(self._pathpreview)
self._preview_extract = PreviewExtract(self._pathpreview)
class PreviewTrigger():
""" Triggers 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) -> None:
logger.debug("Initializing: %s", self.__class__.__name__)
self._trigger_files = {"update": os.path.join(PATHCACHE, ".preview_trigger"),
"mask_toggle": os.path.join(PATHCACHE, ".preview_mask_toggle")}
logger.debug("Initialized: %s (trigger_files: %s)",
self.__class__.__name__, self._trigger_files)
def set(self, trigger_type: T.Literal["update", "mask_toggle"]):
""" Place the trigger file into the cache folder
Parameters
----------
trigger_type: ["update", "mask_toggle"]
The type of action to trigger. 'update': Full preview update. 'mask_toggle': toggle
mask on and off
"""
trigger = self._trigger_files[trigger_type]
if not os.path.isfile(trigger):
with open(trigger, "w", encoding="utf8"):
pass
logger.debug("Set preview trigger: %s", trigger)
def clear(self, trigger_type: T.Literal["update", "mask_toggle"] | None = None) -> None:
""" Remove the trigger file from the cache folder.
Parameters
----------
trigger_type: ["update", "mask_toggle", ``None``], optional
The trigger to clear. 'update': Full preview update. 'mask_toggle': toggle mask on
and off. ``None`` - clear all triggers. Default: ``None``
"""
if trigger_type is None:
triggers = list(self._trigger_files.values())
else:
triggers = [self._trigger_files[trigger_type]]
for trigger in triggers:
if os.path.isfile(trigger):
os.remove(trigger)
logger.debug("Removed preview trigger: %s", trigger)
def preview_trigger() -> PreviewTrigger:
""" Set the global preview trigger if it has not already 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