- preview tool - Prevent tkinter variables from exiting main thread
refactor:
    - preview tool - Split module to smaller sub-modules + docs, locales
Typing:
    - tools.preview.cli
Unit test:
    - tools.preview.viewer
This commit is contained in:
torzdf 2023-01-17 15:03:29 +00:00
parent 80f63280ca
commit 34b558426e
14 changed files with 1789 additions and 1178 deletions

View File

@ -14,3 +14,4 @@ Subpackages
:maxdepth: 1 :maxdepth: 1
lib lib
tools

View File

@ -0,0 +1,15 @@
***************
preview package
***************
.. contents:: Contents
:local:
viewer_test module
******************
Unittests for the :class:`~tools.preview.viewer` module
.. automodule:: tests.tools.preview.viewer_test
:members:
:undoc-members:
:show-inheritance:

14
docs/full/tests/tools.rst Normal file
View File

@ -0,0 +1,14 @@
*************
tools package
*************
.. contents:: Contents
:local:
Subpackages
===========
.. toctree::
:maxdepth: 1
tools.preview

View File

@ -0,0 +1,44 @@
***************
preview package
***************
.. contents:: Contents
:local:
preview module
==============
The Preview Module is the main entry point into the Preview Tool.
.. automodule:: tools.preview.preview
:members:
:undoc-members:
:show-inheritance:
cli module
==========
.. automodule:: tools.preview.cli
:members:
:undoc-members:
:show-inheritance:
control_panels module
=====================
.. automodule:: tools.preview.control_panels
:members:
:undoc-members:
:show-inheritance:
viewer module
=============
.. automodule:: tools.preview.viewer
:members:
:undoc-members:
:show-inheritance:

View File

@ -15,6 +15,7 @@ Subpackages
alignments alignments
manual manual
preview
sort sort
mask module mask module
@ -32,28 +33,3 @@ model module
:members: :members:
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
preview module
===============
.. rubric:: Module Summary
.. autosummary::
:nosignatures:
~tools.preview.preview.ActionFrame
~tools.preview.preview.ConfigFrame
~tools.preview.preview.ConfigTools
~tools.preview.preview.FacesDisplay
~tools.preview.preview.ImagesCanvas
~tools.preview.preview.OptionsBook
~tools.preview.preview.Patch
~tools.preview.preview.Preview
~tools.preview.preview.Samples
.. rubric:: Module
.. automodule:: tools.preview.preview
:members:
:undoc-members:
:show-inheritance:

View File

@ -26,8 +26,8 @@ _CONFIG: Optional["Config"] = None
def initialize_config(root: tk.Tk, def initialize_config(root: tk.Tk,
cli_opts: "CliOptions", cli_opts: Optional["CliOptions"],
statusbar: "StatusBar") -> Optional["Config"]: statusbar: Optional["StatusBar"]) -> Optional["Config"]:
""" Initialize the GUI Master :class:`Config` and add to global constant. """ 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` This should only be called once on first GUI startup. Future access to :class:`Config`
@ -37,10 +37,10 @@ def initialize_config(root: tk.Tk,
---------- ----------
root: :class:`tkinter.Tk` root: :class:`tkinter.Tk`
The root Tkinter object The root Tkinter object
cli_opts: :class:`lib.gui.options.CliOptions` cli_opts: :class:`lib.gui.options.CliOptions` or ``None``
The command line options object The command line options object. Must be provided for main GUI. Must be ``None`` for tools
statusbar: :class:`lib.gui.custom_widgets.StatusBar` statusbar: :class:`lib.gui.custom_widgets.StatusBar` or ``None``
The GUI Status bar The GUI Status bar. Must be provided for main GUI. Must be ``None`` for tools
Returns Returns
------- -------
@ -145,11 +145,11 @@ class GlobalVariables():
@dataclass @dataclass
class _GuiObjects: class _GuiObjects:
""" Data class for commonly accessed GUI Objects """ """ Data class for commonly accessed GUI Objects """
cli_opts: "CliOptions" cli_opts: Optional["CliOptions"]
tk_vars: GlobalVariables tk_vars: GlobalVariables
project: Project project: Project
tasks: Tasks tasks: Tasks
status_bar: "StatusBar" status_bar: Optional["StatusBar"]
default_options: Dict[str, Dict[str, Any]] = field(default_factory=dict) default_options: Dict[str, Dict[str, Any]] = field(default_factory=dict)
command_notebook: Optional["CommandNotebook"] = None command_notebook: Optional["CommandNotebook"] = None
@ -165,12 +165,15 @@ class Config():
---------- ----------
root: :class:`tkinter.Tk` root: :class:`tkinter.Tk`
The root Tkinter object The root Tkinter object
cli_opts: :class:`lib.gui.options.CliOpts` cli_opts: :class:`lib.gui.options.CliOptions` or ``None``
The command line options object The command line options object. Must be provided for main GUI. Must be ``None`` for tools
statusbar: :class:`lib.gui.custom_widgets.StatusBar` statusbar: :class:`lib.gui.custom_widgets.StatusBar` or ``None``
The GUI Status bar The GUI Status bar. Must be provided for main GUI. Must be ``None`` for tools
""" """
def __init__(self, root: tk.Tk, cli_opts: "CliOptions", statusbar: "StatusBar") -> None: def __init__(self,
root: tk.Tk,
cli_opts: Optional["CliOptions"],
statusbar: Optional["StatusBar"]) -> None:
logger.debug("Initializing %s: (root %s, cli_opts: %s, statusbar: %s)", logger.debug("Initializing %s: (root %s, cli_opts: %s, statusbar: %s)",
self.__class__.__name__, root, cli_opts, statusbar) self.__class__.__name__, root, cli_opts, statusbar)
self._default_font = cast(dict, tk.font.nametofont("TkDefaultFont").configure())["family"] self._default_font = cast(dict, tk.font.nametofont("TkDefaultFont").configure())["family"]
@ -210,6 +213,9 @@ class Config():
@property @property
def cli_opts(self) -> "CliOptions": def cli_opts(self) -> "CliOptions":
""" :class:`lib.gui.options.CliOptions`: The command line options for this GUI Session. """ """ :class:`lib.gui.options.CliOptions`: The command line options for this GUI Session. """
# This should only be None when a separate tool (not main GUI) is used, at which point
# cli_opts do not exist
assert self._gui_objects.cli_opts is not None
return self._gui_objects.cli_opts return self._gui_objects.cli_opts
@property @property
@ -236,6 +242,9 @@ class Config():
def statusbar(self) -> "StatusBar": def statusbar(self) -> "StatusBar":
""" :class:`lib.gui.custom_widgets.StatusBar`: The GUI StatusBar """ :class:`lib.gui.custom_widgets.StatusBar`: The GUI StatusBar
:class:`tkinter.ttk.Frame`. """ :class:`tkinter.ttk.Frame`. """
# This should only be None when a separate tool (not main GUI) is used, at which point
# this statusbar does not exist
assert self._gui_objects.status_bar is not None
return self._gui_objects.status_bar return self._gui_objects.status_bar
@property @property

View File

@ -1,72 +1,80 @@
# SOME DESCRIPTIVE TITLE. # SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR ORGANIZATION # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
# #
#, fuzzy
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"POT-Creation-Date: 2021-03-10 16:51-0000\n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-01-16 12:27+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=cp1252\n" "Content-Type: text/plain; charset=CHARSET\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: pygettext.py 1.5\n"
#: tools/preview/cli.py:14
#: ./tools/preview\cli.py:13
msgid "This command allows you to preview swaps to tweak convert settings." msgid "This command allows you to preview swaps to tweak convert settings."
msgstr "" msgstr ""
#: ./tools/preview\cli.py:22 #: tools/preview/cli.py:29
msgid "" msgid ""
"Preview tool\n" "Preview tool\n"
"Allows you to configure your convert settings with a live preview" "Allows you to configure your convert settings with a live preview"
msgstr "" msgstr ""
#: ./tools/preview\cli.py:32 ./tools/preview\cli.py:41 #: tools/preview/cli.py:46 tools/preview/cli.py:55 tools/preview/cli.py:62
#: ./tools/preview\cli.py:48
msgid "data" msgid "data"
msgstr "" msgstr ""
#: ./tools/preview\cli.py:34 #: tools/preview/cli.py:48
msgid "Input directory or video. Either a directory containing the image files you wish to process or path to a video file." msgid ""
"Input directory or video. Either a directory containing the image files you "
"wish to process or path to a video file."
msgstr "" msgstr ""
#: ./tools/preview\cli.py:43 #: tools/preview/cli.py:57
msgid "Path to the alignments file for the input, if not at the default location" msgid ""
"Path to the alignments file for the input, if not at the default location"
msgstr "" msgstr ""
#: ./tools/preview\cli.py:50 #: tools/preview/cli.py:64
msgid "Model directory. A directory containing the trained model you wish to process." msgid ""
"Model directory. A directory containing the trained model you wish to "
"process."
msgstr "" msgstr ""
#: ./tools/preview\cli.py:57 #: tools/preview/cli.py:71
msgid "Swap the model. Instead of A -> B, swap B -> A" msgid "Swap the model. Instead of A -> B, swap B -> A"
msgstr "" msgstr ""
#: ./tools/preview\preview.py:1303 #: tools/preview/control_panels.py:496
msgid "Save full config" msgid "Save full config"
msgstr "" msgstr ""
#: ./tools/preview\preview.py:1306 #: tools/preview/control_panels.py:499
msgid "Reset full config to default values" msgid "Reset full config to default values"
msgstr "" msgstr ""
#: ./tools/preview\preview.py:1309 #: tools/preview/control_panels.py:502
msgid "Reset full config to saved values" msgid "Reset full config to saved values"
msgstr "" msgstr ""
#: ./tools/preview\preview.py:1453 #: tools/preview/control_panels.py:653
msgid "Save {} config" #, python-brace-format
msgid "Save {title} config"
msgstr "" msgstr ""
#: ./tools/preview\preview.py:1456 #: tools/preview/control_panels.py:656
msgid "Reset {} config to default values" #, python-brace-format
msgid "Reset {title} config to default values"
msgstr "" msgstr ""
#: ./tools/preview\preview.py:1459 #: tools/preview/control_panels.py:659
msgid "Reset {} config to saved values" #, python-brace-format
msgid "Reset {title} config to saved values"
msgstr "" msgstr ""

0
tests/tools/__init__.py Normal file
View File

View File

View File

@ -0,0 +1,480 @@
#!/usr/bin python3
""" Pytest unit tests for :mod:`tools.preview.viewer` """
import tkinter as tk
from tkinter import ttk
from typing import cast, TYPE_CHECKING
from unittest.mock import MagicMock
import pytest
import pytest_mock
import numpy as np
from PIL import ImageTk
from lib.logger import log_setup
log_setup("DEBUG", "", "PyTest, False") # Need to setup logging to avoid trace/verbose errors
from lib.utils import get_backend # pylint:disable=wrong-import-position # noqa
from tools.preview.viewer import _Faces, FacesDisplay, ImagesCanvas # pylint:disable=wrong-import-position # noqa
if TYPE_CHECKING:
from lib.align.aligned_face import CenteringType
# pylint:disable=protected-access
def test__faces():
""" Test the :class:`~tools.preview.viewer._Faces dataclass initializes correctly """
faces = _Faces()
assert faces.filenames == []
assert faces.matrix == []
assert faces.src == []
assert faces.dst == []
_PARAMS = [(3, 448), (4, 333), (5, 254), (6, 128)] # columns/face_size
_IDS = [f"cols:{c},size:{s}[{get_backend().upper()}]" for c, s in _PARAMS]
class TestFacesDisplay():
""" Test :class:`~tools.preview.viewer.FacesDisplay """
_padding = 64
def get_faces_display_instance(self, columns: int = 5, face_size: int = 256) -> FacesDisplay:
""" Obtain an instance of :class:`~tools.preview.viewer.FacesDisplay` with the given column
and face size layout.
Parameters
----------
columns: int, optional
The number of columns to display in the viewer, default: 5
face_size: int, optional
The size of each face image to be displayed in the viewer, default: 256
Returns
-------
:class:`~tools.preview.viewer.FacesDisplay`
An instance of the FacesDisplay class at the given settings
"""
app = MagicMock()
retval = FacesDisplay(app, face_size, self._padding)
retval._faces = _Faces(
matrix=[np.random.rand(2, 3) for _ in range(columns)],
src=[np.random.rand(face_size, face_size, 3) for _ in range(columns)],
dst=[np.random.rand(face_size, face_size, 3) for _ in range(columns)])
return retval
def test_init(self) -> None:
""" Test :class:`~tools.preview.viewer.FacesDisplay` __init__ method """
f_display = self.get_faces_display_instance(face_size=256)
assert f_display._size == 256
assert f_display._padding == self._padding
assert isinstance(f_display._app, MagicMock)
assert f_display._display_dims == (1, 1)
assert isinstance(f_display._faces, _Faces)
assert f_display._centering is None
assert f_display._faces_source.size == 0
assert f_display._faces_dest.size == 0
assert f_display._tk_image is None
assert f_display.update_source is False
assert not f_display.source and isinstance(f_display.source, list)
assert not f_display.destination and isinstance(f_display.destination, list)
@pytest.mark.parametrize("columns, face_size", _PARAMS, ids=_IDS)
def test__total_columns(self, columns: int, face_size: int) -> None:
""" Test :class:`~tools.preview.viewer.FacesDisplay` _total_columns property is correctly
calculated
Parameters
----------
columns: int
The number of columns to display in the viewer
face_size: int
The size of each face image to be displayed in the viewer
"""
f_display = self.get_faces_display_instance(columns, face_size)
f_display.source = [None for _ in range(columns)] # type:ignore
assert f_display._total_columns == columns
def test_set_centering(self) -> None:
""" Test :class:`~tools.preview.viewer.FacesDisplay` set_centering method """
f_display = self.get_faces_display_instance()
assert f_display._centering is None
centering: "CenteringType" = "legacy"
f_display.set_centering(centering)
assert f_display._centering == centering
def test_set_display_dimensions(self) -> None:
""" Test :class:`~tools.preview.viewer.FacesDisplay` set_display_dimensions method """
f_display = self.get_faces_display_instance()
assert f_display._display_dims == (1, 1)
dimensions = (800, 600)
f_display.set_display_dimensions(dimensions)
assert f_display._display_dims == dimensions
@pytest.mark.parametrize("columns, face_size", _PARAMS, ids=_IDS)
def test_update_tk_image(self,
columns: int,
face_size: int,
mocker: pytest_mock.MockerFixture) -> None:
""" Test :class:`~tools.preview.viewer.FacesDisplay` update_tk_image method
Parameters
----------
columns: int
The number of columns to display in the viewer
face_size: int
The size of each face image to be displayed in the viewer
mocker: :class:`pytest_mock.MockerFixture`
Mocker for checking _build_faces_image method called
"""
f_display = self.get_faces_display_instance(columns, face_size)
f_display._build_faces_image = cast(MagicMock, mocker.MagicMock()) # type:ignore
f_display._get_scale_size = cast(MagicMock, # type:ignore
mocker.MagicMock(return_value=(128, 128)))
f_display._faces_source = np.zeros((face_size, face_size, 3), dtype=np.uint8)
f_display._faces_dest = np.zeros((face_size, face_size, 3), dtype=np.uint8)
tk.Tk() # tkinter instance needed for image creation
f_display.update_tk_image()
f_display._build_faces_image.assert_called_once()
f_display._get_scale_size.assert_called_once()
assert isinstance(f_display._tk_image, ImageTk.PhotoImage)
assert f_display._tk_image.width() == 128
assert f_display._tk_image.height() == 128
assert f_display.tk_image == f_display._tk_image # public property test
@pytest.mark.parametrize("columns, face_size", _PARAMS, ids=_IDS)
def test_get_scale_size(self, columns: int, face_size: int) -> None:
""" Test :class:`~tools.preview.viewer.FacesDisplay` get_scale_size method
Parameters
----------
columns: int
The number of columns to display in the viewer
face_size: int
The size of each face image to be displayed in the viewer
"""
f_display = self.get_faces_display_instance(columns, face_size)
f_display.set_display_dimensions((800, 600))
img = np.zeros((face_size, face_size, 3), dtype=np.uint8)
size = f_display._get_scale_size(img)
assert size == (600, 600)
@pytest.mark.parametrize("columns, face_size", _PARAMS, ids=_IDS)
def test__build_faces_image(self,
columns: int,
face_size: int,
mocker: pytest_mock.MockerFixture) -> None:
""" Test :class:`~tools.preview.viewer.FacesDisplay` _build_faces_image method
Parameters
----------
columns: int
The number of columns to display in the viewer
face_size: int
The size of each face image to be displayed in the viewer
mocker: :class:`pytest_mock.MockerFixture`
Mocker for checking internal methods called
"""
header_size = 32
f_display = self.get_faces_display_instance(columns, face_size)
f_display._faces_from_frames = cast(MagicMock, mocker.MagicMock()) # type:ignore
f_display._header_text = cast( # type:ignore
MagicMock,
mocker.MagicMock(return_value=np.random.rand(header_size, face_size * columns, 3)))
f_display._draw_rect = cast(MagicMock, # type:ignore
mocker.MagicMock(side_effect=lambda x: x))
# Test full update
f_display.update_source = True
f_display._build_faces_image()
f_display._faces_from_frames.assert_called_once()
f_display._header_text.assert_called_once()
assert f_display._draw_rect.call_count == columns * 2 # src + dst
assert f_display._faces_source.shape == (face_size + header_size, face_size * columns, 3)
assert f_display._faces_dest.shape == (face_size, face_size * columns, 3)
f_display._faces_from_frames.reset_mock()
f_display._header_text.reset_mock()
f_display._draw_rect.reset_mock()
# Test dst update only
f_display.update_source = False
f_display._build_faces_image()
f_display._faces_from_frames.assert_called_once()
assert not f_display._header_text.called
assert f_display._draw_rect.call_count == columns # dst only
assert f_display._faces_dest.shape == (face_size, face_size * columns, 3)
@pytest.mark.parametrize("columns, face_size", _PARAMS, ids=_IDS)
def test_faces__from_frames(self,
columns,
face_size,
mocker: pytest_mock.MockerFixture) -> None:
""" Test :class:`~tools.preview.viewer.FacesDisplay` _from_frames method
Parameters
----------
columns: int
The number of columns to display in the viewer
face_size: int
The size of each face image to be displayed in the viewer
mocker: :class:`pytest_mock.MockerFixture`
Mocker for checking _build_faces_image method called
"""
f_display = self.get_faces_display_instance(columns, face_size)
f_display.source = [mocker.MagicMock() for _ in range(3)]
f_display.destination = [np.random.rand(face_size, face_size, 3) for _ in range(3)]
f_display._crop_source_faces = cast(MagicMock, mocker.MagicMock()) # type:ignore
f_display._crop_destination_faces = cast(MagicMock, mocker.MagicMock()) # type:ignore
# Both src + dst
f_display.update_source = True
f_display._faces_from_frames()
f_display._crop_source_faces.assert_called_once()
f_display._crop_destination_faces.assert_called_once()
f_display._crop_source_faces.reset_mock()
f_display._crop_destination_faces.reset_mock()
# Just dst
f_display.update_source = False
f_display._faces_from_frames()
assert not f_display._crop_source_faces.called
f_display._crop_destination_faces.assert_called_once()
@pytest.mark.parametrize("columns, face_size", _PARAMS, ids=_IDS)
def test__crop_source_faces(self,
columns: int,
face_size: int,
monkeypatch: pytest.MonkeyPatch,
mocker: pytest_mock.MockerFixture) -> None:
""" Test :class:`~tools.preview.viewer.FacesDisplay` _crop_source_faces method
Parameters
----------
columns: int
The number of columns to display in the viewer
face_size: int
The size of each face image to be displayed in the viewer
monkeypatch: :class:`pytest.MonkeyPatch`
For patching the transform_image function
mocker: :class:`pytest_mock.MockerFixture`
Mocker for mocking various internal methods
"""
f_display = self.get_faces_display_instance(columns, face_size)
f_display._centering = "face"
f_display.update_source = True
f_display._faces.src = []
transform_image_mock = mocker.MagicMock()
monkeypatch.setattr("tools.preview.viewer.transform_image", transform_image_mock)
f_display.source = [mocker.MagicMock() for _ in range(columns)]
for idx, mock in enumerate(f_display.source):
assert isinstance(mock, MagicMock)
mock.inbound.detected_faces.__getitem__ = lambda self, x, y=mock: y
mock.aligned.matrix = f"test_matrix_{idx}"
mock.inbound.filename = f"test_filename_{idx}.txt"
f_display._crop_source_faces()
assert len(f_display._faces.filenames) == columns
assert len(f_display._faces.matrix) == columns
assert len(f_display._faces.src) == columns
assert not f_display.update_source
assert transform_image_mock.call_count == columns
for idx in range(columns):
assert f_display._faces.filenames[idx] == f"test_filename_{idx}"
assert f_display._faces.matrix[idx] == f"test_matrix_{idx}"
@pytest.mark.parametrize("columns, face_size", _PARAMS, ids=_IDS)
def test__crop_destination_faces(self,
columns: int,
face_size: int,
mocker: pytest_mock.MockerFixture) -> None:
""" Test :class:`~tools.preview.viewer.FacesDisplay` _crop_destination_faces method
Parameters
----------
columns: int
The number of columns to display in the viewer
face_size: int
The size of each face image to be displayed in the viewer
mocker: :class:`pytest_mock.MockerFixture`
Mocker for dummying in full frames
"""
f_display = self.get_faces_display_instance(columns, face_size)
f_display._centering = "face"
f_display._faces.dst = [] # empty object and test populated correctly
f_display.source = [mocker.MagicMock() for _ in range(columns)]
for item in f_display.source: # type ignore
item.inbound.image = np.random.rand(1280, 720, 3) # type:ignore
f_display._crop_destination_faces()
assert len(f_display._faces.dst) == columns
assert all(f.shape == (face_size, face_size, 3) for f in f_display._faces.dst)
@pytest.mark.parametrize("columns, face_size", _PARAMS, ids=_IDS)
def test__header_text(self,
columns: int,
face_size: int,
mocker: pytest_mock.MockerFixture) -> None:
""" Test :class:`~tools.preview.viewer.FacesDisplay` _header_text method
Parameters
----------
columns: int
The number of columns to display in the viewer
face_size: int
The size of each face image to be displayed in the viewer
mocker: :class:`pytest_mock.MockerFixture`
Mocker for dummying in cv2 calls
"""
f_display = self.get_faces_display_instance(columns, face_size)
f_display.source = [None for _ in range(columns)] # type:ignore
f_display._faces.filenames = [f"filename_{idx}.png" for idx in range(columns)]
cv2_mock = mocker.patch("tools.preview.viewer.cv2")
text_width, text_height = (100, 32)
cv2_mock.getTextSize.return_value = [(text_width, text_height), ]
header_box = f_display._header_text()
assert cv2_mock.getTextSize.call_count == columns
assert cv2_mock.putText.call_count == columns
assert header_box.shape == (face_size // 8, face_size * columns, 3)
@pytest.mark.parametrize("columns, face_size", _PARAMS, ids=_IDS)
def test__draw_rect_text(self,
columns: int,
face_size: int,
mocker: pytest_mock.MockerFixture) -> None:
""" Test :class:`~tools.preview.viewer.FacesDisplay` _draw_rect method
Parameters
----------
columns: int
The number of columns to display in the viewer
face_size: int
The size of each face image to be displayed in the viewer
mocker: :class:`pytest_mock.MockerFixture`
Mocker for dummying in cv2 calls
"""
f_display = self.get_faces_display_instance(columns, face_size)
cv2_mock = mocker.patch("tools.preview.viewer.cv2")
image = (np.random.rand(face_size, face_size, 3) * 255.0) + 50
assert image.max() > 255.0
output = f_display._draw_rect(image)
cv2_mock.rectangle.assert_called_once()
assert output.max() == 255.0 # np.clip
class TestImagesCanvas:
""" Test :class:`~tools.preview.viewer.ImagesCanvas` """
@pytest.fixture
def parent(self) -> MagicMock:
""" Mock object to act as the parent widget to the ImagesCanvas
Returns
--------
:class:`unittest.mock.MagicMock`
The mocked ttk.PanedWindow widget
"""
retval = MagicMock(spec=ttk.PanedWindow)
retval.tk = retval
retval._w = "mock_ttkPanedWindow"
retval.children = {}
retval.call = retval
retval.createcommand = retval
retval.preview_display = MagicMock(spec=FacesDisplay)
return retval
@pytest.fixture(name="images_canvas_instance")
def images_canvas_fixture(self, parent) -> ImagesCanvas:
""" Fixture for creating a testing :class:`~tools.preview.viewer.ImagesCanvas` instance
Parameters
----------
parent: :class:`unittest.mock.MagicMock`
The mocked ttk.PanedWindow parent
Returns
-------
:class:`~tools.preview.viewer.ImagesCanvas`
The class instance for testing
"""
app = MagicMock()
return ImagesCanvas(app, parent)
def test_init(self, images_canvas_instance: ImagesCanvas, parent: MagicMock) -> None:
""" Test :class:`~tools.preview.viewer.ImagesCanvas` __init__ method
Parameters
----------
images_canvas_instance: :class:`~tools.preview.viewer.ImagesCanvas`
The class instance to test
parent: :class:`unittest.mock.MagicMock`
The mocked parent ttk.PanedWindow
"""
assert images_canvas_instance._display == parent.preview_display
assert isinstance(images_canvas_instance._canvas, tk.Canvas)
assert images_canvas_instance._canvas.master == images_canvas_instance
assert images_canvas_instance._canvas.winfo_ismapped()
def test_resize(self,
images_canvas_instance: ImagesCanvas,
parent: MagicMock,
mocker: pytest_mock.MockerFixture) -> None:
""" Test :class:`~tools.preview.viewer.ImagesCanvas` resize method
Parameters
----------
images_canvas_instance: :class:`~tools.preview.viewer.ImagesCanvas`
The class instance to test
parent: :class:`unittest.mock.MagicMock`
The mocked parent ttk.PanedWindow
mocker: :class:`pytest_mock.MockerFixture`
Mocker for dummying in tk calls
"""
event_mock = mocker.MagicMock(spec=tk.Event, width=100, height=200)
images_canvas_instance.reload = cast(MagicMock, mocker.MagicMock()) # type:ignore
images_canvas_instance._resize(event_mock)
parent.preview_display.set_display_dimensions.assert_called_once_with((100, 200))
images_canvas_instance.reload.assert_called_once()
def test_reload(self,
images_canvas_instance: ImagesCanvas,
parent: MagicMock,
mocker: pytest_mock.MockerFixture) -> None:
""" Test :class:`~tools.preview.viewer.ImagesCanvas` reload method
Parameters
----------
images_canvas_instance: :class:`~tools.preview.viewer.ImagesCanvas`
The class instance to test
parent: :class:`unittest.mock.MagicMock`
The mocked parent ttk.PanedWindow
mocker: :class:`pytest_mock.MockerFixture`
Mocker for dummying in tk calls
"""
itemconfig_mock = mocker.patch.object(tk.Canvas, "itemconfig")
images_canvas_instance.reload()
parent.preview_display.update_tk_image.assert_called_once()
itemconfig_mock.assert_called_once()

View File

@ -1,6 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" Command Line Arguments for tools """ """ Command Line Arguments for tools """
import gettext import gettext
from typing import Any, List, Dict
from lib.cli.args import FaceSwapArgs from lib.cli.args import FaceSwapArgs
from lib.cli.actions import DirOrFileFullPaths, DirFullPaths, FileFullPaths from lib.cli.actions import DirOrFileFullPaths, DirFullPaths, FileFullPaths
@ -17,13 +18,26 @@ class PreviewArgs(FaceSwapArgs):
""" Class to parse the command line arguments for Preview (Convert Settings) tool """ """ Class to parse the command line arguments for Preview (Convert Settings) tool """
@staticmethod @staticmethod
def get_info(): def get_info() -> str:
""" Return command information """ """ Return command information
Returns
-------
str
Top line information about the Preview tool
"""
return _("Preview tool\nAllows you to configure your convert settings with a live preview") return _("Preview tool\nAllows you to configure your convert settings with a live preview")
def get_argument_list(self): @staticmethod
def get_argument_list() -> List[Dict[str, Any]]:
""" Put the arguments in a list so that they are accessible from both argparse and gui
argument_list = list() Returns
-------
list[dict[str, Any]]
Top command line options for the preview tool
"""
argument_list = []
argument_list.append(dict( argument_list.append(dict(
opts=("-i", "--input-dir"), opts=("-i", "--input-dir"),
action=DirOrFileFullPaths, action=DirOrFileFullPaths,

View File

@ -0,0 +1,667 @@
#!/usr/bin/env python3
""" Manages the widgets that hold the bottom 'control' area of the preview tool """
import gettext
import logging
import tkinter as tk
from tkinter import ttk
from configparser import ConfigParser
from typing import Any, Callable, cast, Dict, List, Optional, TYPE_CHECKING, Union
from lib.gui.custom_widgets import Tooltip
from lib.gui.control_helper import ControlPanel, ControlPanelOption
from lib.gui.utils import get_images
from plugins.plugin_loader import PluginLoader
from plugins.convert._config import Config
if TYPE_CHECKING:
from .preview import Preview
logger = logging.getLogger(__name__)
# LOCALES
_LANG = gettext.translation("tools.preview", localedir="locales", fallback=True)
_ = _LANG.gettext
class ConfigTools():
""" Tools for loading, saving, setting and retrieving configuration file values.
Attributes
----------
tk_vars: dict
Global tkinter variables. `Refresh` and `Busy` :class:`tkinter.BooleanVar`
"""
def __init__(self) -> None:
self._config = Config(None)
self.tk_vars: Dict[str, Dict[str, Union[tk.BooleanVar,
tk.StringVar,
tk.IntVar,
tk.DoubleVar]]] = {}
self._config_dicts = self._get_config_dicts() # Holds currently saved config
@property
def config(self) -> Config:
""" :class:`plugins.convert._config.Config` The convert configuration """
return self._config
@property
def config_dicts(self) -> Dict[str, Any]:
""" dict: The convert configuration options in dictionary form."""
return self._config_dicts
@property
def sections(self) -> List[str]:
""" list: The sorted section names that exist within the convert Configuration options. """
return sorted(set(plugin.split(".")[0] for plugin in self._config.config.sections()
if plugin.split(".")[0] != "writer"))
@property
def plugins_dict(self) -> Dict[str, List[str]]:
""" dict: Dictionary of configuration option sections as key with a list of containing
plugins as the value """
return {section: sorted([plugin.split(".")[1] for plugin in self._config.config.sections()
if plugin.split(".")[0] == section])
for section in self.sections}
def update_config(self) -> None:
""" Update :attr:`config` with the currently selected values from the GUI. """
for section, items in self.tk_vars.items():
for item, value in items.items():
try:
new_value = str(value.get())
except tk.TclError as err:
# When manually filling in text fields, blank values will
# raise an error on numeric data types so return 0
logger.debug("Error getting value. Defaulting to 0. Error: %s", str(err))
new_value = str(0)
old_value = self._config.config[section][item]
if new_value != old_value:
logger.trace("Updating config: %s, %s from %s to %s", # type: ignore
section, item, old_value, new_value)
self._config.config[section][item] = new_value
def _get_config_dicts(self) -> Dict[str, Dict[str, Any]]:
""" Obtain a custom configuration dictionary for convert configuration items in use
by the preview tool formatted for control helper.
Returns
-------
dict
Each configuration section as keys, with the values as a dict of option:
:class:`lib.gui.control_helper.ControlOption` pairs. """
logger.debug("Formatting Config for GUI")
config_dicts: Dict[str, Dict[str, Any]] = {}
for section in self._config.config.sections():
if section.startswith("writer."):
continue
for key, val in self._config.defaults[section].items():
if key == "helptext":
config_dicts.setdefault(section, {})[key] = val
continue
cp_option = ControlPanelOption(title=key,
dtype=val["type"],
group=val["group"],
default=val["default"],
initial_value=self._config.get(section, key),
choices=val["choices"],
is_radio=val["gui_radio"],
rounding=val["rounding"],
min_max=val["min_max"],
helptext=val["helptext"])
self.tk_vars.setdefault(section, {})[key] = cp_option.tk_var
config_dicts.setdefault(section, {})[key] = cp_option
logger.debug("Formatted Config for GUI: %s", config_dicts)
return config_dicts
def reset_config_to_saved(self, section: Optional[str] = None) -> None:
""" Reset the GUI parameters to their saved values within the configuration file.
Parameters
----------
section: str, optional
The configuration section to reset the values for, If ``None`` provided then all
sections are reset. Default: ``None``
"""
logger.debug("Resetting to saved config: %s", section)
sections = [section] if section is not None else list(self.tk_vars.keys())
for config_section in sections:
for item, options in self._config_dicts[config_section].items():
if item == "helptext":
continue
val = options.value
if val != self.tk_vars[config_section][item].get():
self.tk_vars[config_section][item].set(val)
logger.debug("Setting %s - %s to saved value %s", config_section, item, val)
logger.debug("Reset to saved config: %s", section)
def reset_config_to_default(self, section: Optional[str] = None) -> None:
""" Reset the GUI parameters to their default configuration values.
Parameters
----------
section: str, optional
The configuration section to reset the values for, If ``None`` provided then all
sections are reset. Default: ``None``
"""
logger.debug("Resetting to default: %s", section)
sections = [section] if section is not None else list(self.tk_vars.keys())
for config_section in sections:
for item, options in self._config_dicts[config_section].items():
if item == "helptext":
continue
default = options.default
if default != self.tk_vars[config_section][item].get():
self.tk_vars[config_section][item].set(default)
logger.debug("Setting %s - %s to default value %s",
config_section, item, default)
logger.debug("Reset to default: %s", section)
def save_config(self, section: Optional[str] = None) -> None:
""" Save the configuration ``.ini`` file with the currently stored values.
Notes
-----
We cannot edit the existing saved config as comments tend to get removed, so we create
a new config and populate that.
Parameters
----------
section: str, optional
The configuration section to save, If ``None`` provided then all sections are saved.
Default: ``None``
"""
logger.debug("Saving %s config", section)
new_config = ConfigParser(allow_no_value=True)
for config_section, items in self._config.defaults.items():
logger.debug("Adding section: '%s')", config_section)
self._config.insert_config_section(config_section,
items["helptext"],
config=new_config)
for item, options in items.items():
if item == "helptext":
continue # helptext already written at top
if ((section is not None and config_section != section)
or config_section not in self.tk_vars):
# retain saved values that have not been updated
new_opt = self._config.get(config_section, item)
logger.debug("Retaining option: (item: '%s', value: '%s')", item, new_opt)
else:
new_opt = self.tk_vars[config_section][item].get()
logger.debug("Setting option: (item: '%s', value: '%s')", item, new_opt)
# Set config_dicts value to new saved value
self._config_dicts[config_section][item].set_initial_value(new_opt)
helptext = self._config.format_help(options["helptext"], is_section=False)
new_config.set(config_section, helptext)
new_config.set(config_section, item, str(new_opt))
self._config.config = new_config
self._config.save_config()
logger.info("Saved config: '%s'", self._config.configfile)
class BusyProgressBar():
""" An infinite progress bar for when a thread is running to swap/patch a group of samples """
def __init__(self, parent: ttk.Frame) -> None:
self._progress_bar = self._add_busy_indicator(parent)
def _add_busy_indicator(self, parent: ttk.Frame) -> ttk.Progressbar:
""" Place progress bar into bottom bar to indicate when processing.
Parameters
----------
parent: tkinter object
The tkinter object that holds the busy indicator
Returns
-------
ttk.Progressbar
A Progress bar to indicate that the Preview tool is busy
"""
logger.debug("Placing busy indicator")
pbar = ttk.Progressbar(parent, mode="indeterminate")
pbar.pack(side=tk.LEFT)
pbar.pack_forget()
return pbar
def stop(self) -> None:
""" Stop and hide progress bar """
logger.debug("Stopping busy indicator")
if not self._progress_bar.winfo_ismapped():
logger.debug("busy indicator already hidden")
return
self._progress_bar.stop()
self._progress_bar.pack_forget()
def start(self) -> None:
""" Start and display progress bar """
logger.debug("Starting busy indicator")
if self._progress_bar.winfo_ismapped():
logger.debug("busy indicator already started")
return
self._progress_bar.pack(side=tk.LEFT, padx=5, pady=(5, 10), fill=tk.X, expand=True)
self._progress_bar.start(25)
class ActionFrame(ttk.Frame): # pylint: disable=too-many-ancestors
""" Frame that holds the left hand side options panel containing the command line options.
Parameters
----------
app: :class:`Preview`
The main tkinter Preview app
parent: tkinter object
The parent tkinter object that holds the Action Frame
"""
def __init__(self, app: 'Preview', parent: ttk.Frame) -> None:
logger.debug("Initializing %s: (app: %s, parent: %s)",
self.__class__.__name__, app, parent)
self._app = app
super().__init__(parent)
self.pack(side=tk.LEFT, anchor=tk.N, fill=tk.Y)
self._tk_vars: Dict[str, tk.StringVar] = {}
self._options = dict(
color=app._patch.converter.cli_arguments.color_adjustment.replace("-", "_"),
mask_type=app._patch.converter.cli_arguments.mask_type.replace("-", "_"))
defaults = {opt: self._format_to_display(val)
for opt, val in self._options.items()}
self._busy_bar = self._build_frame(defaults,
app._samples.generate,
app._refresh,
app._samples.available_masks,
app._samples.predictor.has_predicted_mask)
@property
def convert_args(self) -> Dict[str, Any]:
""" dict: Currently selected Command line arguments from the :class:`ActionFrame`. """
return {opt if opt != "color" else "color_adjustment":
self._format_from_display(self._tk_vars[opt].get())
for opt in self._options}
@property
def busy_progress_bar(self) -> BusyProgressBar:
""" :class:`BusyProgressBar`: The progress bar that appears on the left hand side whilst a
swap/patch is being applied """
return self._busy_bar
@staticmethod
def _format_from_display(var: str) -> str:
""" Format a variable from the display version to the command line action version.
Parameters
----------
var: str
The variable name to format
Returns
-------
str
The formatted variable name
"""
return var.replace(" ", "_").lower()
@staticmethod
def _format_to_display(var: str) -> str:
""" Format a variable from the command line action version to the display version.
Parameters
----------
var: str
The variable name to format
Returns
-------
str
The formatted variable name
"""
return var.replace("_", " ").replace("-", " ").title()
def _build_frame(self,
defaults: Dict[str, Any],
refresh_callback: Callable[[], None],
patch_callback: Callable[[], None],
available_masks: List[str],
has_predicted_mask: bool) -> BusyProgressBar:
""" Build the :class:`ActionFrame`.
Parameters
----------
defaults: dict
The default command line options
patch_callback: python function
The function to execute when a patch callback is received
refresh_callback: python function
The function to execute when a refresh callback is received
available_masks: list
The available masks that exist within the alignments file
has_predicted_mask: bool
Whether the model was trained with a mask
Returns
-------
ttk.Progressbar
A Progress bar to indicate that the Preview tool is busy
"""
logger.debug("Building Action frame")
bottom_frame = ttk.Frame(self)
bottom_frame.pack(side=tk.BOTTOM, fill=tk.X, anchor=tk.S)
top_frame = ttk.Frame(self)
top_frame.pack(side=tk.TOP, fill=tk.BOTH, anchor=tk.N, expand=True)
self._add_cli_choices(top_frame, defaults, available_masks, has_predicted_mask)
busy_indicator = BusyProgressBar(bottom_frame)
self._add_refresh_button(bottom_frame, refresh_callback)
self._add_patch_callback(patch_callback)
self._add_actions(bottom_frame)
logger.debug("Built Action frame")
return busy_indicator
def _add_cli_choices(self,
parent: ttk.Frame,
defaults: Dict[str, Any],
available_masks: List[str],
has_predicted_mask: bool) -> None:
""" Create :class:`lib.gui.control_helper.ControlPanel` object for the command
line options.
parent: :class:`ttk.Frame`
The frame to hold the command line choices
defaults: dict
The default command line options
available_masks: list
The available masks that exist within the alignments file
has_predicted_mask: bool
Whether the model was trained with a mask
"""
cp_options = self._get_control_panel_options(defaults, available_masks, has_predicted_mask)
panel_kwargs = dict(blank_nones=False, label_width=10, style="CPanel")
ControlPanel(parent, cp_options, header_text=None, **panel_kwargs)
def _get_control_panel_options(self,
defaults: Dict[str, Any],
available_masks: List[str],
has_predicted_mask: bool) -> List[ControlPanelOption]:
""" Create :class:`lib.gui.control_helper.ControlPanelOption` objects for the command
line options.
defaults: dict
The default command line options
available_masks: list
The available masks that exist within the alignments file
has_predicted_mask: bool
Whether the model was trained with a mask
Returns
-------
list
The list of `lib.gui.control_helper.ControlPanelOption` objects for the Action Frame
"""
cp_options: List[ControlPanelOption] = []
for opt in self._options:
if opt == "mask_type":
choices = self._create_mask_choices(defaults, available_masks, has_predicted_mask)
else:
choices = PluginLoader.get_available_convert_plugins(opt, True)
cp_option = ControlPanelOption(title=opt,
dtype=str,
default=defaults[opt],
initial_value=defaults[opt],
choices=choices,
group="Command Line Choices",
is_radio=False)
self._tk_vars[opt] = cp_option.tk_var
cp_options.append(cp_option)
return cp_options
def _create_mask_choices(self,
defaults: Dict[str, Any],
available_masks: List[str],
has_predicted_mask: bool) -> List[str]:
""" Set the mask choices and default mask based on available masks.
Parameters
----------
defaults: dict
The default command line options
available_masks: list
The available masks that exist within the alignments file
has_predicted_mask: bool
Whether the model was trained with a mask
Returns
-------
list
The masks that are available to use from the alignments file
"""
logger.debug("Initial mask choices: %s", available_masks)
if has_predicted_mask:
available_masks += ["predicted"]
if "none" not in available_masks:
available_masks += ["none"]
if self._format_from_display(defaults["mask_type"]) not in available_masks:
logger.debug("Setting default mask to first available: %s", available_masks[0])
defaults["mask_type"] = available_masks[0]
logger.debug("Final mask choices: %s", available_masks)
return available_masks
@classmethod
def _add_refresh_button(cls,
parent: ttk.Frame,
refresh_callback: Callable[[], None]) -> None:
""" Add a button to refresh the images.
Parameters
----------
refresh_callback: python function
The function to execute when the refresh button is pressed
"""
btn = ttk.Button(parent, text="Update Samples", command=refresh_callback)
btn.pack(padx=5, pady=5, side=tk.TOP, fill=tk.X, anchor=tk.N)
def _add_patch_callback(self, patch_callback: Callable[[], None]) -> None:
""" Add callback to re-patch images on action option change.
Parameters
----------
patch_callback: python function
The function to execute when the images require patching
"""
for tk_var in self._tk_vars.values():
tk_var.trace("w", patch_callback)
def _add_actions(self, parent: ttk.Frame) -> None:
""" Add Action Buttons to the :class:`ActionFrame`
Parameters
----------
parent: tkinter object
The tkinter object that holds the action buttons
"""
logger.debug("Adding util buttons")
frame = ttk.Frame(parent)
frame.pack(padx=5, pady=(5, 10), side=tk.RIGHT, fill=tk.X, anchor=tk.E)
for utl in ("save", "clear", "reload"):
logger.debug("Adding button: '%s'", utl)
img = get_images().icons[utl]
if utl == "save":
text = _("Save full config")
action = self._app.config_tools.save_config
elif utl == "clear":
text = _("Reset full config to default values")
action = self._app.config_tools.reset_config_to_default
elif utl == "reload":
text = _("Reset full config to saved values")
action = self._app.config_tools.reset_config_to_saved
btnutl = ttk.Button(frame,
image=img,
command=action)
btnutl.pack(padx=2, side=tk.RIGHT)
Tooltip(btnutl, text=text, wrap_length=200)
logger.debug("Added util buttons")
class OptionsBook(ttk.Notebook): # pylint:disable=too-many-ancestors
""" The notebook that holds the Convert configuration options.
Parameters
----------
parent: tkinter object
The parent tkinter object that holds the Options book
config_tools: :class:`ConfigTools`
Tools for loading and saving configuration files
patch_callback: python function
The function to execute when a patch callback is received
Attributes
----------
config_tools: :class:`ConfigTools`
Tools for loading and saving configuration files
"""
def __init__(self,
parent: ttk.Frame,
config_tools: ConfigTools,
patch_callback: Callable[[], None]) -> None:
logger.debug("Initializing %s: (parent: %s, config: %s)",
self.__class__.__name__, parent, config_tools)
super().__init__(parent)
self.pack(side=tk.RIGHT, anchor=tk.N, fill=tk.BOTH, expand=True)
self.config_tools = config_tools
self._tabs: Dict[str, Dict[str, Union[ttk.Notebook, ConfigFrame]]] = {}
self._build_tabs()
self._build_sub_tabs()
self._add_patch_callback(patch_callback)
logger.debug("Initialized %s", self.__class__.__name__)
def _build_tabs(self) -> None:
""" Build the notebook tabs for the each configuration section. """
logger.debug("Build Tabs")
for section in self.config_tools.sections:
tab = ttk.Notebook(self)
self._tabs[section] = {"tab": tab}
self.add(tab, text=section.replace("_", " ").title())
def _build_sub_tabs(self) -> None:
""" Build the notebook sub tabs for each convert section's plugin. """
for section, plugins in self.config_tools.plugins_dict.items():
for plugin in plugins:
config_key = ".".join((section, plugin))
config_dict = self.config_tools.config_dicts[config_key]
tab = ConfigFrame(self, config_key, config_dict)
self._tabs[section][plugin] = tab
text = plugin.replace("_", " ").title()
cast(ttk.Notebook, self._tabs[section]["tab"]).add(tab, text=text)
def _add_patch_callback(self, patch_callback: Callable[[], None]) -> None:
""" Add callback to re-patch images on configuration option change.
Parameters
----------
patch_callback: python function
The function to execute when the images require patching
"""
for plugins in self.config_tools.tk_vars.values():
for tk_var in plugins.values():
tk_var.trace("w", patch_callback)
class ConfigFrame(ttk.Frame): # pylint: disable=too-many-ancestors
""" Holds the configuration options for a convert plugin inside the :class:`OptionsBook`.
Parameters
----------
parent: tkinter object
The tkinter object that will hold this configuration frame
config_key: str
The section/plugin key for these configuration options
options: dict
The options for this section/plugin
"""
def __init__(self,
parent: OptionsBook,
config_key: str,
options: Dict[str, Any]):
logger.debug("Initializing %s", self.__class__.__name__)
super().__init__(parent)
self.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
self._options = options
self._action_frame = ttk.Frame(self)
self._action_frame.pack(padx=0, pady=(0, 5), side=tk.BOTTOM, fill=tk.X, anchor=tk.E)
self._add_frame_separator()
self._build_frame(parent, config_key)
logger.debug("Initialized %s", self.__class__.__name__)
def _build_frame(self, parent: OptionsBook, config_key: str) -> None:
""" Build the options frame for this command
Parameters
----------
parent: tkinter object
The tkinter object that will hold this configuration frame
config_key: str
The section/plugin key for these configuration options
"""
logger.debug("Add Config Frame")
panel_kwargs = dict(columns=2, option_columns=2, blank_nones=False, style="CPanel")
frame = ttk.Frame(self)
frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
cp_options = [opt for key, opt in self._options.items() if key != "helptext"]
ControlPanel(frame, cp_options, header_text=None, **panel_kwargs)
self._add_actions(parent, config_key)
logger.debug("Added Config Frame")
def _add_frame_separator(self) -> None:
""" Add a separator between top and bottom frames. """
logger.debug("Add frame seperator")
sep = ttk.Frame(self._action_frame, height=2, relief=tk.RIDGE)
sep.pack(fill=tk.X, pady=5, side=tk.TOP)
logger.debug("Added frame seperator")
def _add_actions(self, parent: OptionsBook, config_key: str) -> None:
""" Add Action Buttons.
Parameters
----------
parent: tkinter object
The tkinter object that will hold this configuration frame
config_key: str
The section/plugin key for these configuration options
"""
logger.debug("Adding util buttons")
title = config_key.split(".")[1].replace("_", " ").title()
btn_frame = ttk.Frame(self._action_frame)
btn_frame.pack(padx=5, side=tk.BOTTOM, fill=tk.X)
for utl in ("save", "clear", "reload"):
logger.debug("Adding button: '%s'", utl)
img = get_images().icons[utl]
if utl == "save":
text = _(f"Save {title} config")
action = parent.config_tools.save_config
elif utl == "clear":
text = _(f"Reset {title} config to default values")
action = parent.config_tools.reset_config_to_default
elif utl == "reload":
text = _(f"Reset {title} config to saved values")
action = parent.config_tools.reset_config_to_saved
btnutl = ttk.Button(btn_frame,
image=img,
command=lambda cmd=action: cmd(config_key)) # type: ignore
btnutl.pack(padx=2, side=tk.RIGHT)
Tooltip(btnutl, text=text, wrap_length=200)
logger.debug("Added util buttons")

File diff suppressed because it is too large Load Diff

295
tools/preview/viewer.py Normal file
View File

@ -0,0 +1,295 @@
#!/usr/bin/env python3
""" Manages the widgets that hold the top 'viewer' area of the preview tool """
import logging
import os
import tkinter as tk
from tkinter import ttk
from dataclasses import dataclass, field
from typing import cast, List, Optional, Tuple, TYPE_CHECKING
import cv2
import numpy as np
from PIL import Image, ImageTk
from lib.align import transform_image
from lib.align.aligned_face import CenteringType
from scripts.convert import ConvertItem
if TYPE_CHECKING:
from .preview import Preview
logger = logging.getLogger(__name__)
@dataclass
class _Faces:
""" Dataclass for holding faces """
filenames: List[str] = field(default_factory=list)
matrix: List[np.ndarray] = field(default_factory=list)
src: List[np.ndarray] = field(default_factory=list)
dst: List[np.ndarray] = field(default_factory=list)
class FacesDisplay():
""" Compiles the 2 rows of sample faces (original and swapped) into a single image
Parameters
----------
app: :class:`Preview`
The main tkinter Preview app
size: int
The size of each individual face sample in pixels
padding: int
The amount of extra padding to apply to the outside of the face
Attributes
----------
update_source: bool
Flag to indicate that the source images for the preview have been updated, so the preview
should be recompiled.
source: list
The list of :class:`numpy.ndarray` source preview images for top row of display
destination: list
The list of :class:`numpy.ndarray` swapped and patched preview images for bottom row of
display
"""
def __init__(self, app: 'Preview', size: int, padding: int) -> None:
logger.trace("Initializing %s: (app: %s, size: %s, padding: %s)", # type: ignore
self.__class__.__name__, app, size, padding)
self._size = size
self._display_dims = (1, 1)
self._app = app
self._padding = padding
self._faces = _Faces()
self._centering: Optional[CenteringType] = None
self._faces_source: np.ndarray = np.array([])
self._faces_dest: np.ndarray = np.array([])
self._tk_image: Optional[ImageTk.PhotoImage] = None
# Set from Samples
self.update_source = False
self.source: List[ConvertItem] = [] # Source images, filenames + detected faces
# Set from Patch
self.destination: List[np.ndarray] = [] # Swapped + patched images
logger.trace("Initialized %s", self.__class__.__name__) # type: ignore
@property
def tk_image(self) -> Optional[ImageTk.PhotoImage]:
""" :class:`PIL.ImageTk.PhotoImage`: The compiled preview display in tkinter display
format """
return self._tk_image
@property
def _total_columns(self) -> int:
""" int: The total number of images that are being displayed """
return len(self.source)
def set_centering(self, centering: CenteringType) -> None:
""" The centering that the model uses is not known at initialization time.
Set :attr:`_centering` when the model has been loaded.
Parameters
----------
centering: str
The centering that the model was trained on
"""
self._centering = centering
def set_display_dimensions(self, dimensions: Tuple[int, int]) -> None:
""" Adjust the size of the frame that will hold the preview samples.
Parameters
----------
dimensions: tuple
The (`width`, `height`) of the frame that holds the preview
"""
self._display_dims = dimensions
def update_tk_image(self) -> None:
""" Build the full preview images and compile :attr:`tk_image` for display. """
logger.trace("Updating tk image") # type: ignore
self._build_faces_image()
img = np.vstack((self._faces_source, self._faces_dest))
size = self._get_scale_size(img)
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
pilimg = Image.fromarray(img)
pilimg = pilimg.resize(size, Image.ANTIALIAS)
self._tk_image = ImageTk.PhotoImage(pilimg)
logger.trace("Updated tk image") # type: ignore
def _get_scale_size(self, image: np.ndarray) -> Tuple[int, int]:
""" Get the size that the full preview image should be resized to fit in the
display window.
Parameters
----------
image: :class:`numpy.ndarray`
The full sized compiled preview image
Returns
-------
tuple
The (`width`, `height`) that the display image should be sized to fit in the display
window
"""
frameratio = float(self._display_dims[0]) / float(self._display_dims[1])
imgratio = float(image.shape[1]) / float(image.shape[0])
if frameratio <= imgratio:
scale = self._display_dims[0] / float(image.shape[1])
size = (self._display_dims[0], max(1, int(image.shape[0] * scale)))
else:
scale = self._display_dims[1] / float(image.shape[0])
size = (max(1, int(image.shape[1] * scale)), self._display_dims[1])
logger.trace("scale: %s, size: %s", scale, size) # type: ignore
return size
def _build_faces_image(self) -> None:
""" Compile the source and destination rows of the preview image. """
logger.trace("Building Faces Image") # type: ignore
update_all = self.update_source
self._faces_from_frames()
if update_all:
header = self._header_text()
source = np.hstack([self._draw_rect(face) for face in self._faces.src])
self._faces_source = np.vstack((header, source))
self._faces_dest = np.hstack([self._draw_rect(face) for face in self._faces.dst])
logger.debug("source row shape: %s, swapped row shape: %s",
self._faces_dest.shape, self._faces_source.shape)
def _faces_from_frames(self) -> None:
""" Extract the preview faces from the source frames and apply the requisite padding. """
logger.debug("Extracting faces from frames: Number images: %s", len(self.source))
if self.update_source:
self._crop_source_faces()
self._crop_destination_faces()
logger.debug("Extracted faces from frames: %s",
{k: len(v) for k, v in self._faces.__dict__.items()})
def _crop_source_faces(self) -> None:
""" Extract the source faces from the source frames, along with their filenames and the
transformation matrix used to extract the faces. """
logger.debug("Updating source faces")
self._faces = _Faces() # Init new class
for item in self.source:
detected_face = item.inbound.detected_faces[0]
src_img = item.inbound.image
detected_face.load_aligned(src_img,
size=self._size,
centering=cast(CenteringType, self._centering))
matrix = detected_face.aligned.matrix
self._faces.filenames.append(os.path.splitext(item.inbound.filename)[0])
self._faces.matrix.append(matrix)
self._faces.src.append(transform_image(src_img, matrix, self._size, self._padding))
self.update_source = False
logger.debug("Updated source faces")
def _crop_destination_faces(self) -> None:
""" Extract the swapped faces from the swapped frames using the source face destination
matrices. """
logger.debug("Updating destination faces")
self._faces.dst = []
destination = self.destination if self.destination else [np.ones_like(src.inbound.image)
for src in self.source]
for idx, image in enumerate(destination):
self._faces.dst.append(transform_image(image,
self._faces.matrix[idx],
self._size,
self._padding))
logger.debug("Updated destination faces")
def _header_text(self) -> np.ndarray:
""" Create the header text displaying the frame name for each preview column.
Returns
-------
:class:`numpy.ndarray`
The header row of the preview image containing the frame names for each column
"""
font_scale = self._size / 640
height = self._size // 8
font = cv2.FONT_HERSHEY_SIMPLEX
# Get size of placed text for positioning
text_sizes = [cv2.getTextSize(self._faces.filenames[idx],
font,
font_scale,
1)[0]
for idx in range(self._total_columns)]
# Get X and Y co-ordinates for each text item
text_y = int((height + text_sizes[0][1]) / 2)
text_x = [int((self._size - text_sizes[idx][0]) / 2) + self._size * idx
for idx in range(self._total_columns)]
logger.debug("filenames: %s, text_sizes: %s, text_x: %s, text_y: %s",
self._faces.filenames, text_sizes, text_x, text_y)
header_box = np.ones((height, self._size * self._total_columns, 3), np.uint8) * 255
for idx, text in enumerate(self._faces.filenames):
cv2.putText(header_box,
text,
(text_x[idx], text_y),
font,
font_scale,
(0, 0, 0),
1,
lineType=cv2.LINE_AA)
logger.debug("header_box.shape: %s", header_box.shape)
return header_box
def _draw_rect(self, image: np.ndarray) -> np.ndarray:
""" Place a white border around a given image.
Parameters
----------
image: :class:`numpy.ndarray`
The image to place a border on to
Returns
-------
:class:`numpy.ndarray`
The given image with a border drawn around the outside
"""
cv2.rectangle(image, (0, 0), (self._size - 1, self._size - 1), (255, 255, 255), 1)
image = np.clip(image, 0.0, 255.0)
return image.astype("uint8")
class ImagesCanvas(ttk.Frame): # pylint:disable=too-many-ancestors
""" tkinter Canvas that holds the preview images.
Parameters
----------
app: :class:`Preview`
The main tkinter Preview app
parent: tkinter object
The parent tkinter object that holds the canvas
"""
def __init__(self, app: 'Preview', parent: ttk.PanedWindow) -> None:
logger.debug("Initializing %s: (app: %s, parent: %s)",
self.__class__.__name__, app, parent)
super().__init__(parent)
self.pack(expand=True, fill=tk.BOTH, padx=2, pady=2)
self._display: FacesDisplay = parent.preview_display # type: ignore
self._canvas = tk.Canvas(self, bd=0, highlightthickness=0)
self._canvas.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
self._displaycanvas = self._canvas.create_image(0, 0,
image=self._display.tk_image,
anchor=tk.NW)
self.bind("<Configure>", self._resize)
logger.debug("Initialized %s", self.__class__.__name__)
def _resize(self, event: tk.Event) -> None:
""" Resize the image to fit the frame, maintaining aspect ratio """
logger.debug("Resizing preview image")
framesize = (event.width, event.height)
self._display.set_display_dimensions(framesize)
self.reload()
def reload(self) -> None:
""" Update the images in the canvas and redraw """
logger.debug("Reloading preview image")
self._display.update_tk_image()
self._canvas.itemconfig(self._displaycanvas, image=self._display.tk_image)
logger.debug("Reloaded preview image")