Back to Repositories

Testing Face Preview Display Components in Faceswap

This test suite validates the viewer component of the Faceswap project, focusing on the face preview display functionality. The tests ensure proper handling of face image rendering, layout management, and UI interactions within the preview window.

Test Coverage Overview

Comprehensive test coverage for the preview viewer module, including dataclass initialization, face display handling, and canvas management.

Key functionality tested:
  • Face data structure initialization and management
  • Dynamic layout calculations and image scaling
  • Image processing and transformation
  • UI component interactions and event handling

Implementation Analysis

The testing approach utilizes pytest fixtures and mocking to isolate UI components and validate display logic. Implements parametrized tests for various display configurations and image sizes.

Notable patterns include:
  • Mock objects for tkinter widgets and display components
  • Parametrized tests for different column/face size combinations
  • Fixture-based test setup for repeatable UI component testing

Technical Details

Testing tools and configuration:
  • pytest for test framework and assertions
  • pytest-mock for mocking functionality
  • numpy for image array manipulation
  • PIL/tkinter for image display handling
  • Custom logging configuration for debugging

Best Practices Demonstrated

The test suite exemplifies high-quality testing practices through comprehensive coverage and careful component isolation.

Notable practices include:
  • Thorough mock usage for external dependencies
  • Parametrized testing for multiple scenarios
  • Clear test organization by component class
  • Proper handling of UI widget lifecycle

deepfakes/faceswap

tests/tools/preview/viewer_test.py

            
#!/usr/bin python3
""" Pytest unit tests for :mod:`tools.preview.viewer` """
from __future__ import annotations
import tkinter as tk
import typing as T

from tkinter import ttk

from unittest.mock import MagicMock

import pytest
import pytest_mock
import numpy as np
from PIL import ImageTk

from lib.logger import log_setup
# Need to setup logging to avoid trace/verbose errors
log_setup("DEBUG", "pytest_viewer.log", "PyTest, False")

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 T.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 = T.cast(MagicMock, mocker.MagicMock())  # type:ignore
        f_display._get_scale_size = T.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 = T.cast(MagicMock, mocker.MagicMock())  # type:ignore
        f_display._header_text = T.cast(  # type:ignore
            MagicMock,
            mocker.MagicMock(return_value=np.random.rand(header_size, face_size * columns, 3)))
        f_display._draw_rect = T.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 = T.cast(MagicMock, mocker.MagicMock())  # type:ignore
        f_display._crop_destination_faces = T.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 = T.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()