From 1c081aea7da9134c0b78e308a07959f01ddb1d86 Mon Sep 17 00:00:00 2001 From: torzdf <36920800+torzdf@users.noreply.github.com> Date: Mon, 15 Apr 2024 12:19:15 +0100 Subject: [PATCH] Add ability to export and import alignment data (#1383) * tools.alignments - add export job * plugins.extract: Update __repr__ for ExtractorBatch dataclass * plugins.extract: Initial implementation of external import plugins * plugins.extract: Disable lm masks on ROI alignment data import * lib.align: Add `landmark_type` property to AlignedFace and return dummy data for ROI Landmarks pose estimate * plugins.extract: Add centering config item for align import and fix filename mapping for images * plugins.extract: Log warning on downstream plugins on limited alignment data * tools: Fix plugins for 4 point ROI landmarks (alignments, sort, mask) * tools.manual: Fix for 2D-4 ROI landmarks * training: Fix for 4 point ROI landmarks * lib.convert: Average color plugin. Avoid divide by zero errors * extract - external: - Default detector to 'external' when importing alignments - Handle different frame origin co-ordinates * alignments: Store video extension in alignments file * plugins.extract.external: Handle video file keys * plugins.extract.external: Output warning if missing data * locales + docs * plugins.extract.align.external: Roll the corner points to top-left for different origins * Clean up * linting fix --- docs/full/lib/align.rst | 30 +- docs/full/plugins/extract.rst | 18 +- docs/full/tools/manual.faceviewer.rst | 24 +- lib/align/__init__.py | 9 +- lib/align/aligned_face.py | 329 ++----- lib/align/alignments.py | 6 +- lib/align/constants.py | 111 +++ lib/align/detected_face.py | 28 +- lib/align/pose.py | 187 ++++ lib/cli/args_extract_convert.py | 8 +- lib/image.py | 8 +- lib/training/cache.py | 29 +- .../lib.cli.args_extract_convert.mo | Bin 31533 -> 31746 bytes .../lib.cli.args_extract_convert.po | 151 ++-- .../es/LC_MESSAGES/tools.alignments.cli.mo | Bin 11850 -> 12801 bytes .../es/LC_MESSAGES/tools.alignments.cli.po | 44 +- .../lib.cli.args_extract_convert.mo | Bin 31900 -> 32085 bytes .../lib.cli.args_extract_convert.po | 150 ++-- .../kr/LC_MESSAGES/tools.alignments.cli.mo | Bin 11572 -> 12494 bytes .../kr/LC_MESSAGES/tools.alignments.cli.po | 41 +- locales/lib.cli.args_extract_convert.pot | 131 ++- .../lib.cli.args_extract_convert.mo | Bin 42512 -> 42768 bytes .../lib.cli.args_extract_convert.po | 153 ++-- .../ru/LC_MESSAGES/tools.alignments.cli.mo | Bin 15152 -> 16449 bytes .../ru/LC_MESSAGES/tools.alignments.cli.po | 43 +- locales/tools.alignments.cli.pot | 34 +- plugins/convert/color/avg_color.py | 28 +- plugins/extract/__init__.py | 4 + plugins/extract/_base.py | 27 +- plugins/extract/align/_base/aligner.py | 52 +- plugins/extract/align/external.py | 277 ++++++ plugins/extract/align/external_defaults.py | 97 +++ plugins/extract/detect/_base.py | 21 +- plugins/extract/detect/external.py | 353 ++++++++ plugins/extract/detect/external_defaults.py | 79 ++ plugins/extract/extract_media.py | 210 +++++ plugins/extract/mask/_base.py | 42 +- plugins/extract/mask/components.py | 7 + plugins/extract/mask/extended.py | 8 + plugins/extract/pipeline.py | 340 +++----- plugins/extract/recognition/_base.py | 34 +- scripts/convert.py | 7 +- scripts/extract.py | 11 +- scripts/fsmedia.py | 21 +- tools/alignments/alignments.py | 4 +- tools/alignments/cli.py | 11 +- tools/alignments/jobs.py | 112 ++- tools/alignments/jobs_frames.py | 23 +- tools/manual/detected_faces.py | 2 +- tools/manual/faceviewer/frame.py | 285 ++++--- tools/manual/faceviewer/interact.py | 423 +++++++++ tools/manual/faceviewer/viewport.py | 804 ++++++------------ tools/manual/frameviewer/editor/landmarks.py | 63 +- tools/manual/frameviewer/frame.py | 106 +-- tools/manual/manual.py | 6 +- tools/mask/loader.py | 2 +- tools/mask/mask.py | 4 +- tools/mask/mask_generate.py | 4 +- tools/mask/mask_import.py | 8 +- tools/preview/preview.py | 2 +- tools/sort/sort_methods.py | 44 +- tools/sort/sort_methods_aligned.py | 11 +- 62 files changed, 3331 insertions(+), 1735 deletions(-) create mode 100644 lib/align/constants.py create mode 100644 lib/align/pose.py create mode 100644 plugins/extract/align/external.py create mode 100644 plugins/extract/align/external_defaults.py create mode 100644 plugins/extract/detect/external.py create mode 100644 plugins/extract/detect/external_defaults.py create mode 100644 plugins/extract/extract_media.py create mode 100644 tools/manual/faceviewer/interact.py diff --git a/docs/full/lib/align.rst b/docs/full/lib/align.rst index bb449f8..cb01196 100644 --- a/docs/full/lib/align.rst +++ b/docs/full/lib/align.rst @@ -7,6 +7,7 @@ The align Package handles detected faces, their alignments and masks. .. contents:: Contents :local: + aligned\_face module ==================== @@ -16,10 +17,9 @@ Handles aligned faces and corresponding pose estimates .. autosummary:: :nosignatures: - + ~lib.align.aligned_face.AlignedFace ~lib.align.aligned_face.get_matrix_scaling - ~lib.align.aligned_face.PoseEstimate ~lib.align.aligned_face.transform_image .. rubric:: Module @@ -29,6 +29,7 @@ Handles aligned faces and corresponding pose estimates :undoc-members: :show-inheritance: + alignments module ================= @@ -38,7 +39,7 @@ Handles alignments stored in a serialized alignments.fsa file .. autosummary:: :nosignatures: - + ~lib.align.alignments.Alignments ~lib.align.alignments.Thumbnails @@ -49,6 +50,17 @@ Handles alignments stored in a serialized alignments.fsa file :undoc-members: :show-inheritance: + +constants module +================ +Holds various constants for use in generating and manipulating aligned face images + +.. automodule:: lib.align.constants + :members: + :undoc-members: + :show-inheritance: + + detected\_face module ===================== @@ -58,7 +70,7 @@ Handles detected face objects and their associated masks. .. autosummary:: :nosignatures: - + ~lib.align.detected_face.BlurMask ~lib.align.detected_face.DetectedFace ~lib.align.detected_face.Mask @@ -70,3 +82,13 @@ Handles detected face objects and their associated masks. :members: :undoc-members: :show-inheritance: + + +pose module +=========== +Handles pose estimates based on aligned face data + +.. automodule:: lib.align.pose + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/full/plugins/extract.rst b/docs/full/plugins/extract.rst index 5eb3cae..f7e441a 100755 --- a/docs/full/plugins/extract.rst +++ b/docs/full/plugins/extract.rst @@ -8,18 +8,16 @@ The Extract Package handles the various plugins available for extracting face se :local: +extract\_media module +===================== +.. automodule:: plugins.extract.extract_media + :members: + :undoc-members: + :show-inheritance: + + pipeline module =============== -.. rubric:: Module Summary - -.. autosummary:: - :nosignatures: - - ~plugins.extract.pipeline.ExtractMedia - ~plugins.extract.pipeline.Extractor - -.. rubric:: Module - .. automodule:: plugins.extract.pipeline :members: :undoc-members: diff --git a/docs/full/tools/manual.faceviewer.rst b/docs/full/tools/manual.faceviewer.rst index ed3209f..5589e14 100644 --- a/docs/full/tools/manual.faceviewer.rst +++ b/docs/full/tools/manual.faceviewer.rst @@ -7,6 +7,7 @@ Handles the display of faces in the Face Viewer section of Faceswap's Manual Too .. contents:: Contents :local: + frame module ============ @@ -28,6 +29,27 @@ frame module :undoc-members: :show-inheritance: + +interact module +=============== + +.. rubric:: Module Summary + +.. autosummary:: + :nosignatures: + + ~tools.manual.faceviewer.interact.ActiveFrame + ~tools.manual.faceviewer.interact.Asset + ~tools.manual.faceviewer.interact.HoverBox + +.. rubric:: Module + +.. automodule:: tools.manual.faceviewer.interact + :members: + :undoc-members: + :show-inheritance: + + viewport module =============== @@ -36,8 +58,6 @@ viewport module .. autosummary:: :nosignatures: - ~tools.manual.faceviewer.viewport.ActiveFrame - ~tools.manual.faceviewer.viewport.HoverBox ~tools.manual.faceviewer.viewport.TKFace ~tools.manual.faceviewer.viewport.Viewport ~tools.manual.faceviewer.viewport.VisibleObjects diff --git a/lib/align/__init__.py b/lib/align/__init__.py index d599199..ec00ec7 100644 --- a/lib/align/__init__.py +++ b/lib/align/__init__.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 """ Package for handling alignments files, detected faces and aligned faces along with their associated objects. """ -from .aligned_face import (AlignedFace, _EXTRACT_RATIOS, get_adjusted_center, # noqa - get_matrix_scaling, get_centered_size, PoseEstimate, transform_image) -from .alignments import Alignments # noqa -from .detected_face import BlurMask, DetectedFace, Mask, update_legacy_png_header # noqa +from .aligned_face import (AlignedFace, get_adjusted_center, get_matrix_scaling, + get_centered_size, transform_image) +from .alignments import Alignments +from .constants import CenteringType, EXTRACT_RATIOS, LANDMARK_PARTS, LandmarkType +from .detected_face import BlurMask, DetectedFace, Mask, update_legacy_png_header diff --git a/lib/align/aligned_face.py b/lib/align/aligned_face.py index 263b03c..41f2eed 100644 --- a/lib/align/aligned_face.py +++ b/lib/align/aligned_face.py @@ -1,63 +1,22 @@ #!/usr/bin/env python3 """ Aligner for faceswap.py """ +from __future__ import annotations from dataclasses import dataclass, field import logging import typing as T + from threading import Lock import cv2 import numpy as np +from lib.logger import parse_class_init + +from .constants import CenteringType, EXTRACT_RATIOS, LandmarkType, _MEAN_FACE +from .pose import PoseEstimate + logger = logging.getLogger(__name__) -CenteringType = T.Literal["face", "head", "legacy"] - -_MEAN_FACE = np.array([[0.010086, 0.106454], [0.085135, 0.038915], [0.191003, 0.018748], - [0.300643, 0.034489], [0.403270, 0.077391], [0.596729, 0.077391], - [0.699356, 0.034489], [0.808997, 0.018748], [0.914864, 0.038915], - [0.989913, 0.106454], [0.500000, 0.203352], [0.500000, 0.307009], - [0.500000, 0.409805], [0.500000, 0.515625], [0.376753, 0.587326], - [0.435909, 0.609345], [0.500000, 0.628106], [0.564090, 0.609345], - [0.623246, 0.587326], [0.131610, 0.216423], [0.196995, 0.178758], - [0.275698, 0.179852], [0.344479, 0.231733], [0.270791, 0.245099], - [0.192616, 0.244077], [0.655520, 0.231733], [0.724301, 0.179852], - [0.803005, 0.178758], [0.868389, 0.216423], [0.807383, 0.244077], - [0.729208, 0.245099], [0.264022, 0.780233], [0.350858, 0.745405], - [0.438731, 0.727388], [0.500000, 0.742578], [0.561268, 0.727388], - [0.649141, 0.745405], [0.735977, 0.780233], [0.652032, 0.864805], - [0.566594, 0.902192], [0.500000, 0.909281], [0.433405, 0.902192], - [0.347967, 0.864805], [0.300252, 0.784792], [0.437969, 0.778746], - [0.500000, 0.785343], [0.562030, 0.778746], [0.699747, 0.784792], - [0.563237, 0.824182], [0.500000, 0.831803], [0.436763, 0.824182]]) - -_MEAN_FACE_3D = np.array([[4.056931, -11.432347, 1.636229], # 8 chin LL - [1.833492, -12.542305, 4.061275], # 7 chin L - [0.0, -12.901019, 4.070434], # 6 chin C - [-1.833492, -12.542305, 4.061275], # 5 chin R - [-4.056931, -11.432347, 1.636229], # 4 chin RR - [6.825897, 1.275284, 4.402142], # 33 L eyebrow L - [1.330353, 1.636816, 6.903745], # 29 L eyebrow R - [-1.330353, 1.636816, 6.903745], # 34 R eyebrow L - [-6.825897, 1.275284, 4.402142], # 38 R eyebrow R - [1.930245, -5.060977, 5.914376], # 54 nose LL - [0.746313, -5.136947, 6.263227], # 53 nose L - [0.0, -5.485328, 6.76343], # 52 nose C - [-0.746313, -5.136947, 6.263227], # 51 nose R - [-1.930245, -5.060977, 5.914376], # 50 nose RR - [5.311432, 0.0, 3.987654], # 13 L eye L - [1.78993, -0.091703, 4.413414], # 17 L eye R - [-1.78993, -0.091703, 4.413414], # 25 R eye L - [-5.311432, 0.0, 3.987654], # 21 R eye R - [2.774015, -7.566103, 5.048531], # 43 mouth L - [0.509714, -7.056507, 6.566167], # 42 mouth top L - [0.0, -7.131772, 6.704956], # 41 mouth top C - [-0.509714, -7.056507, 6.566167], # 40 mouth top R - [-2.774015, -7.566103, 5.048531], # 39 mouth R - [-0.589441, -8.443925, 6.109526], # 46 mouth bottom R - [0.0, -8.601736, 6.097667], # 45 mouth bottom C - [0.589441, -8.443925, 6.109526]]) # 44 mouth bottom L - -_EXTRACT_RATIOS = {"legacy": 0.375, "face": 0.5, "head": 0.625} def get_matrix_scaling(matrix: np.ndarray) -> tuple[int, int]: @@ -82,7 +41,7 @@ def get_matrix_scaling(matrix: np.ndarray) -> tuple[int, int]: interpolators = cv2.INTER_CUBIC, cv2.INTER_AREA else: interpolators = cv2.INTER_AREA, cv2.INTER_CUBIC - logger.trace("interpolator: %s, inverse interpolator: %s", # type: ignore + logger.trace("interpolator: %s, inverse interpolator: %s", # type:ignore[attr-defined] interpolators[0], interpolators[1]) return interpolators @@ -109,7 +68,7 @@ def transform_image(image: np.ndarray, :class:`numpy.ndarray` The transformed image """ - logger.trace("image shape: %s, matrix: %s, size: %s. padding: %s", # type: ignore + logger.trace("image shape: %s, matrix: %s, size: %s. padding: %s", # type:ignore[attr-defined] image.shape, matrix, size, padding) # transform the matrix for size and padding mat = matrix * (size - 2 * padding) @@ -118,7 +77,7 @@ def transform_image(image: np.ndarray, # transform image interpolators = get_matrix_scaling(mat) retval = cv2.warpAffine(image, mat, (size, size), flags=interpolators[0]) - logger.trace("transformed matrix: %s, final image shape: %s", # type: ignore + logger.trace("transformed matrix: %s, final image shape: %s", # type:ignore[attr-defined] mat, image.shape) return retval @@ -146,13 +105,14 @@ def get_adjusted_center(image_size: int, :class:`numpy.ndarray` The center point of the image at the given size for the target centering """ - source_size = image_size - (image_size * _EXTRACT_RATIOS[source_centering]) + source_size = image_size - (image_size * EXTRACT_RATIOS[source_centering]) offset = target_offset - source_offset offset *= source_size center = np.rint(offset + image_size / 2).astype("int32") - logger.trace("image_size: %s, source_offset: %s, target_offset: %s, " # type: ignore - "source_centering: '%s', adjusted_offset: %s, center: %s", image_size, - source_offset, target_offset, source_centering, offset, center) + logger.trace( # type:ignore[attr-defined] + "image_size: %s, source_offset: %s, target_offset: %s, source_centering: '%s', " + "adjusted_offset: %s, center: %s", + image_size, source_offset, target_offset, source_centering, offset, center) return center @@ -196,158 +156,16 @@ def get_centered_size(source_centering: CenteringType, if source_centering == target_centering and coverage_ratio == 1.0: retval = size else: - src_size = size - (size * _EXTRACT_RATIOS[source_centering]) - retval = 2 * int(np.rint((src_size / (1 - _EXTRACT_RATIOS[target_centering]) + src_size = size - (size * EXTRACT_RATIOS[source_centering]) + retval = 2 * int(np.rint((src_size / (1 - EXTRACT_RATIOS[target_centering]) * coverage_ratio) / 2)) - logger.trace("source_centering: %s, target_centering: %s, size: %s, " # type: ignore - "coverage_ratio: %s, source_size: %s, crop_size: %s", source_centering, - target_centering, size, coverage_ratio, src_size, retval) + logger.trace( # type:ignore[attr-defined] + "source_centering: %s, target_centering: %s, size: %s, coverage_ratio: %s, " + "source_size: %s, crop_size: %s", + source_centering, target_centering, size, coverage_ratio, src_size, retval) return retval -class PoseEstimate(): - """ Estimates pose from a generic 3D head model for the given 2D face landmarks. - - Parameters - ---------- - landmarks: :class:`numpy.ndarry` - The original 68 point landmarks aligned to 0.0 - 1.0 range - - References - ---------- - Head Pose Estimation using OpenCV and Dlib - https://www.learnopencv.com/tag/solvepnp/ - 3D Model points - http://aifi.isr.uc.pt/Downloads/OpenGL/glAnthropometric3DModel.cpp - """ - def __init__(self, landmarks: np.ndarray) -> None: - self._distortion_coefficients = np.zeros((4, 1)) # Assuming no lens distortion - self._xyz_2d: np.ndarray | None = None - - self._camera_matrix = self._get_camera_matrix() - self._rotation, self._translation = self._solve_pnp(landmarks) - self._offset = self._get_offset() - self._pitch_yaw_roll: tuple[float, float, float] = (0, 0, 0) - - @property - def xyz_2d(self) -> np.ndarray: - """ :class:`numpy.ndarray` projected (x, y) coordinates for each x, y, z point at a - constant distance from adjusted center of the skull (0.5, 0.5) in the 2D space. """ - if self._xyz_2d is None: - xyz = cv2.projectPoints(np.array([[6., 0., -2.3], - [0., 6., -2.3], - [0., 0., 3.7]]).astype("float32"), - self._rotation, - self._translation, - self._camera_matrix, - self._distortion_coefficients)[0].squeeze() - self._xyz_2d = xyz - self._offset["head"] - return self._xyz_2d - - @property - def offset(self) -> dict[CenteringType, np.ndarray]: - """ dict: The amount to offset a standard 0.0 - 1.0 umeyama transformation matrix for a - from the center of the face (between the eyes) or center of the head (middle of skull) - rather than the nose area. """ - return self._offset - - @property - def pitch(self) -> float: - """ float: The pitch of the aligned face in eular angles """ - if not any(self._pitch_yaw_roll): - self._get_pitch_yaw_roll() - return self._pitch_yaw_roll[0] - - @property - def yaw(self) -> float: - """ float: The yaw of the aligned face in eular angles """ - if not any(self._pitch_yaw_roll): - self._get_pitch_yaw_roll() - return self._pitch_yaw_roll[1] - - @property - def roll(self) -> float: - """ float: The roll of the aligned face in eular angles """ - if not any(self._pitch_yaw_roll): - self._get_pitch_yaw_roll() - return self._pitch_yaw_roll[2] - - def _get_pitch_yaw_roll(self) -> None: - """ Obtain the yaw, roll and pitch from the :attr:`_rotation` in eular angles. """ - proj_matrix = np.zeros((3, 4), dtype="float32") - proj_matrix[:3, :3] = cv2.Rodrigues(self._rotation)[0] - euler = cv2.decomposeProjectionMatrix(proj_matrix)[-1] - self._pitch_yaw_roll = T.cast(tuple[float, float, float], tuple(euler.squeeze())) - logger.trace("yaw_pitch: %s", self._pitch_yaw_roll) # type: ignore - - @classmethod - def _get_camera_matrix(cls) -> np.ndarray: - """ Obtain an estimate of the camera matrix based off the original frame dimensions. - - Returns - ------- - :class:`numpy.ndarray` - An estimated camera matrix - """ - focal_length = 4 - camera_matrix = np.array([[focal_length, 0, 0.5], - [0, focal_length, 0.5], - [0, 0, 1]], dtype="double") - logger.trace("camera_matrix: %s", camera_matrix) # type: ignore - return camera_matrix - - def _solve_pnp(self, landmarks: np.ndarray) -> tuple[np.ndarray, np.ndarray]: - """ Solve the Perspective-n-Point for the given landmarks. - - Takes 2D landmarks in world space and estimates the rotation and translation vectors - in 3D space. - - Parameters - ---------- - landmarks: :class:`numpy.ndarry` - The original 68 point landmark co-ordinates relating to the original frame - - Returns - ------- - rotation: :class:`numpy.ndarray` - The solved rotation vector - translation: :class:`numpy.ndarray` - The solved translation vector - """ - points = landmarks[[6, 7, 8, 9, 10, 17, 21, 22, 26, 31, 32, 33, 34, - 35, 36, 39, 42, 45, 48, 50, 51, 52, 54, 56, 57, 58]] - _, rotation, translation = cv2.solvePnP(_MEAN_FACE_3D, - points, - self._camera_matrix, - self._distortion_coefficients, - flags=cv2.SOLVEPNP_ITERATIVE) - logger.trace("points: %s, rotation: %s, translation: %s", # type: ignore - points, rotation, translation) - return rotation, translation - - def _get_offset(self) -> dict[CenteringType, np.ndarray]: - """ Obtain the offset between the original center of the extracted face to the new center - of the head in 2D space. - - Returns - ------- - :class:`numpy.ndarray` - The x, y offset of the new center from the old center. - """ - offset: dict[CenteringType, np.ndarray] = {"legacy": np.array([0.0, 0.0])} - points: dict[T.Literal["face", "head"], tuple[float, ...]] = {"head": (0.0, 0.0, -2.3), - "face": (0.0, -1.5, 4.2)} - - for key, pnts in points.items(): - center = cv2.projectPoints(np.array([pnts]).astype("float32"), - self._rotation, - self._translation, - self._camera_matrix, - self._distortion_coefficients)[0].squeeze() - logger.trace("center %s: %s", key, center) # type: ignore - offset[key] = center - (0.5, 0.5) - logger.trace("offset: %s", offset) # type: ignore - return offset - - @dataclass class _FaceCache: # pylint:disable=too-many-instance-attributes """ Cache for storing items related to a single aligned face. @@ -464,27 +282,26 @@ class AlignedFace(): dtype: str | None = None, is_aligned: bool = False, is_legacy: bool = False) -> None: - logger.trace("Initializing: %s (image shape: %s, centering: '%s', " # type: ignore - "size: %s, coverage_ratio: %s, dtype: %s, is_aligned: %s, is_legacy: %s)", - self.__class__.__name__, image if image is None else image.shape, - centering, size, coverage_ratio, dtype, is_aligned, is_legacy) + logger.trace(parse_class_init(locals())) # type:ignore[attr-defined] self._frame_landmarks = landmarks + self._landmark_type = LandmarkType.from_shape(landmarks.shape) self._centering = centering self._size = size self._coverage_ratio = coverage_ratio self._dtype = dtype self._is_aligned = is_aligned self._source_centering: CenteringType = "legacy" if is_legacy and is_aligned else "head" - self._matrices = {"legacy": _umeyama(landmarks[17:], _MEAN_FACE, True)[0:2], - "face": np.array([]), - "head": np.array([])} self._padding = self._padding_from_coverage(size, coverage_ratio) + lookup = self._landmark_type + self._mean_lookup = LandmarkType.LM_2D_51 if lookup == LandmarkType.LM_2D_68 else lookup + self._cache = _FaceCache() + self._matrices: dict[CenteringType, np.ndarray] = {"legacy": self._get_default_matrix()} self._face = self.extract_face(image) - logger.trace("Initialized: %s (matrix: %s, padding: %s, face shape: %s)", # type: ignore - self.__class__.__name__, self._matrices["legacy"], self._padding, + logger.trace("Initialized: %s (padding: %s, face shape: %s)", # type:ignore[attr-defined] + self.__class__.__name__, self._padding, self._face if self._face is None else self._face.shape) @property @@ -508,11 +325,11 @@ class AlignedFace(): """ :class:`numpy.ndarray`: The 3x2 transformation matrix for extracting and aligning the core face area out of the original frame, with no padding or sizing applied. The returned matrix is offset for the given :attr:`centering`. """ - if not np.any(self._matrices[self._centering]): + if self._centering not in self._matrices: matrix = self._matrices["legacy"].copy() matrix[:, 2] -= self.pose.offset[self._centering] self._matrices[self._centering] = matrix - logger.trace("original matrix: %s, new matrix: %s", # type: ignore + logger.trace("original matrix: %s, new matrix: %s", # type:ignore[attr-defined] self._matrices["legacy"], matrix) return self._matrices[self._centering] @@ -523,7 +340,7 @@ class AlignedFace(): if self._cache.pose is None: lms = np.nan_to_num(cv2.transform(np.expand_dims(self._frame_landmarks, axis=1), self._matrices["legacy"]).squeeze()) - self._cache.pose = PoseEstimate(lms) + self._cache.pose = PoseEstimate(lms, self._landmark_type) return self._cache.pose @property @@ -535,7 +352,7 @@ class AlignedFace(): matrix = self.matrix.copy() mat = matrix * (self._size - 2 * self.padding) mat[:, 2] += self.padding - logger.trace("adjusted_matrix: %s", mat) # type: ignore + logger.trace("adjusted_matrix: %s", mat) # type:ignore[attr-defined] self._cache.adjusted_matrix = mat return self._cache.adjusted_matrix @@ -557,7 +374,7 @@ class AlignedFace(): [self._size - 1, self._size - 1], [self._size - 1, 0]]) roi = np.rint(self.transform_points(roi, invert=True)).astype("int32") - logger.trace("original roi: %s", roi) # type: ignore + logger.trace("original roi: %s", roi) # type:ignore[attr-defined] self._cache.original_roi = roi return self._cache.original_roi @@ -568,10 +385,15 @@ class AlignedFace(): with self._cache.lock("landmarks"): if self._cache.landmarks is None: lms = self.transform_points(self._frame_landmarks) - logger.trace("aligned landmarks: %s", lms) # type: ignore + logger.trace("aligned landmarks: %s", lms) # type:ignore[attr-defined] self._cache.landmarks = lms return self._cache.landmarks + @property + def landmark_type(self) -> LandmarkType: + """:class:`~LandmarkType`: The type of landmarks that generated this aligned face """ + return self._landmark_type + @property def normalized_landmarks(self) -> np.ndarray: """ :class:`numpy.ndarray`: The 68 point facial landmarks normalized to 0.0 - 1.0 as @@ -579,8 +401,8 @@ class AlignedFace(): with self._cache.lock("landmarks_normalized"): if self._cache.landmarks_normalized is None: lms = np.expand_dims(self._frame_landmarks, axis=1) - lms = cv2.transform(lms, self._matrices["legacy"], lms.shape).squeeze() - logger.trace("normalized landmarks: %s", lms) # type: ignore + lms = cv2.transform(lms, self._matrices["legacy"]).squeeze() + logger.trace("normalized landmarks: %s", lms) # type:ignore[attr-defined] self._cache.landmarks_normalized = lms return self._cache.landmarks_normalized @@ -590,7 +412,7 @@ class AlignedFace(): with self._cache.lock("interpolators"): if not any(self._cache.interpolators): interpolators = get_matrix_scaling(self.adjusted_matrix) - logger.trace("interpolators: %s", interpolators) # type: ignore + logger.trace("interpolators: %s", interpolators) # type:ignore[attr-defined] self._cache.interpolators = interpolators return self._cache.interpolators @@ -600,8 +422,12 @@ class AlignedFace(): used for aligning the image. """ with self._cache.lock("average_distance"): if not self._cache.average_distance: - average_distance = np.mean(np.abs(self.normalized_landmarks[17:] - _MEAN_FACE)) - logger.trace("average_distance: %s", average_distance) # type: ignore + mean_face = _MEAN_FACE[self._mean_lookup] + lms = self.normalized_landmarks + if self._landmark_type == LandmarkType.LM_2D_68: + lms = lms[17:] # 68 point landmarks only use core face items + average_distance = np.mean(np.abs(lms - mean_face)) + logger.trace("average_distance: %s", average_distance) # type:ignore[attr-defined] self._cache.average_distance = average_distance return self._cache.average_distance @@ -612,10 +438,13 @@ class AlignedFace(): mouth, negative values indicate that eyes/eyebrows are misaligned below the mouth. """ with self._cache.lock("relative_eye_mouth_position"): if not self._cache.relative_eye_mouth_position: - lowest_eyes = np.max(self.normalized_landmarks[np.r_[17:27, 36:48], 1]) - highest_mouth = np.min(self.normalized_landmarks[48:68, 1]) - position = highest_mouth - lowest_eyes - logger.trace("lowest_eyes: %s, highest_mouth: %s, " # type: ignore + if self._landmark_type != LandmarkType.LM_2D_68: + position = 1.0 # arbitrary positive value + else: + lowest_eyes = np.max(self.normalized_landmarks[np.r_[17:27, 36:48], 1]) + highest_mouth = np.min(self.normalized_landmarks[48:68, 1]) + position = highest_mouth - lowest_eyes + logger.trace("lowest_eyes: %s, highest_mouth: %s, " # type:ignore[attr-defined] "relative_eye_mouth_position: %s", lowest_eyes, highest_mouth, position) self._cache.relative_eye_mouth_position = position @@ -638,9 +467,24 @@ class AlignedFace(): dict The padding required, in pixels for 'head', 'face' and 'legacy' face types """ - retval = {_type: round((size * (coverage_ratio - (1 - _EXTRACT_RATIOS[_type]))) / 2) + retval = {_type: round((size * (coverage_ratio - (1 - EXTRACT_RATIOS[_type]))) / 2) for _type in T.get_args(T.Literal["legacy", "face", "head"])} - logger.trace(retval) # type: ignore + logger.trace(retval) # type:ignore[attr-defined] + return retval + + def _get_default_matrix(self) -> np.ndarray: + """ Get the default (legacy) matrix. All subsequent matrices are calculated from this + + Returns + ------- + :class:`numpy.ndarray` + The default 'legacy' matrix + """ + lms = self._frame_landmarks + if self._landmark_type == LandmarkType.LM_2D_68: + lms = lms[17:] # 68 point landmarks only use core face items + retval = _umeyama(lms, _MEAN_FACE[self._mean_lookup], True)[0:2] + logger.trace("Default matrix: %s", retval) # type:ignore[attr-defined] return retval def transform_points(self, points: np.ndarray, invert: bool = False) -> np.ndarray: @@ -662,9 +506,9 @@ class AlignedFace(): """ retval = np.expand_dims(points, axis=1) mat = cv2.invertAffineTransform(self.adjusted_matrix) if invert else self.adjusted_matrix - retval = cv2.transform(retval, mat, retval.shape).squeeze() - logger.trace("invert: %s, Original points: %s, transformed points: %s", # type: ignore - invert, points, retval) + retval = cv2.transform(retval, mat).squeeze() + logger.trace( # type:ignore[attr-defined] + "invert: %s, Original points: %s, transformed points: %s", invert, points, retval) return retval def extract_face(self, image: np.ndarray | None) -> np.ndarray | None: @@ -684,8 +528,8 @@ class AlignedFace(): ``None`` if no image has been provided. """ if image is None: - logger.trace("_extract_face called without a loaded image. " # type: ignore - "Returning empty face.") + logger.trace("_extract_face called without a loaded " # type:ignore[attr-defined] + "image. Returning empty face.") return None if self._is_aligned and (self._centering != self._source_centering or @@ -721,8 +565,9 @@ class AlignedFace(): :class:`numpy.ndarray` The aligned image with the correct centering, scaled to image input size """ - logger.trace("image_size: %s, target_size: %s, coverage_ratio: %s", # type: ignore - image.shape[0], self.size, self._coverage_ratio) + logger.trace( # type:ignore[attr-defined] + "image_size: %s, target_size: %s, coverage_ratio: %s", + image.shape[0], self.size, self._coverage_ratio) img_size = image.shape[0] target_size = get_centered_size(self._source_centering, @@ -733,8 +578,9 @@ class AlignedFace(): slices = self._get_cropped_slices(img_size, target_size) out[slices["out"][0], slices["out"][1], :] = image[slices["in"][0], slices["in"][1], :] - logger.trace("Cropped from aligned extract: (centering: %s, in shape: %s, " # type: ignore - "out shape: %s)", self._centering, image.shape, out.shape) + logger.trace( # type:ignore[attr-defined] + "Cropped from aligned extract: (centering: %s, in shape: %s, out shape: %s)", + self._centering, image.shape, out.shape) return out def _get_cropped_slices(self, @@ -766,7 +612,7 @@ class AlignedFace(): slice(max(roi[0] * -1, 0), target_size - min(target_size, max(0, roi[2] - image_size)))) self._cache.cropped_slices[self._centering] = {"in": slice_in, "out": slice_out} - logger.trace("centering: %s, cropped_slices: %s", # type: ignore + logger.trace("centering: %s, cropped_slices: %s", # type:ignore[attr-defined] self._centering, self._cache.cropped_slices[self._centering]) return self._cache.cropped_slices[self._centering] @@ -805,8 +651,9 @@ class AlignedFace(): self._source_centering) padding = target_size // 2 roi = np.array([center - padding, center + padding]).ravel() - logger.trace("centering: '%s', center: %s, padding: %s, " # type: ignore - "sub roi: %s", centering, center, padding, roi) + logger.trace( # type:ignore[attr-defined] + "centering: '%s', center: %s, padding: %s, sub roi: %s", + centering, center, padding, roi) self._cache.cropped_roi[centering] = roi return self._cache.cropped_roi[centering] diff --git a/lib/align/alignments.py b/lib/align/alignments.py index 7013a7b..c5d7452 100644 --- a/lib/align/alignments.py +++ b/lib/align/alignments.py @@ -252,12 +252,14 @@ class Alignments(): sample_filename = next(fname for fname in self.data) basename = sample_filename[:sample_filename.rfind("_")] - logger.debug("sample filename: %s, base filename: %s", sample_filename, basename) + ext = os.path.splitext(sample_filename)[-1] + logger.debug("sample filename: '%s', base filename: '%s' extension: '%s'", + sample_filename, basename, ext) logger.info("Saving video meta information to Alignments file") for idx, pts in enumerate(pts_time): meta: dict[str, float | int] = {"pts_time": pts, "keyframe": idx in keyframes} - key = f"{basename}_{idx + 1:06d}.png" + key = f"{basename}_{idx + 1:06d}{ext}" if key not in self.data: self.data[key] = {"video_meta": meta, "faces": []} else: diff --git a/lib/align/constants.py b/lib/align/constants.py new file mode 100644 index 0000000..6d5b484 --- /dev/null +++ b/lib/align/constants.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +""" Constants that are required across faceswap's lib.align package """ +from __future__ import annotations + +import typing as T +from enum import Enum + +import numpy as np + +CenteringType = T.Literal["face", "head", "legacy"] + +EXTRACT_RATIOS: dict[CenteringType, float] = {"legacy": 0.375, "face": 0.5, "head": 0.625} +"""dict[Literal["legacy", "face", head"] float]: The amount of padding applied to each +centering type when generating aligned faces """ + + +class LandmarkType(Enum): + """ Enumeration for the landmark types that Faceswap supports """ + LM_2D_4 = 1 + LM_2D_51 = 2 + LM_2D_68 = 3 + LM_3D_26 = 4 + + @classmethod + def from_shape(cls, shape: tuple[int, ...]) -> LandmarkType: + """ The landmark type for a given shape + + Parameters + ---------- + shape: tuple[int, ...] + The shape to get the landmark type for + + Returns + ------- + Type[LandmarkType] + The enum for the given shape + + Raises + ------ + ValueError + If the requested shape is not valid + """ + shapes: dict[tuple[int, ...], LandmarkType] = {(4, 2): cls.LM_2D_4, + (51, 2): cls.LM_2D_51, + (68, 2): cls.LM_2D_68, + (26, 3): cls.LM_3D_26} + if shape not in shapes: + raise ValueError(f"The given shape {shape} is not valid. Valid shapes: {list(shapes)}") + return shapes[shape] + + +_MEAN_FACE: dict[LandmarkType, np.ndarray] = { + LandmarkType.LM_2D_4: np.array( + [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]), # Clockwise from TL + LandmarkType.LM_2D_51: np.array([ + [0.010086, 0.106454], [0.085135, 0.038915], [0.191003, 0.018748], [0.300643, 0.034489], + [0.403270, 0.077391], [0.596729, 0.077391], [0.699356, 0.034489], [0.808997, 0.018748], + [0.914864, 0.038915], [0.989913, 0.106454], [0.500000, 0.203352], [0.500000, 0.307009], + [0.500000, 0.409805], [0.500000, 0.515625], [0.376753, 0.587326], [0.435909, 0.609345], + [0.500000, 0.628106], [0.564090, 0.609345], [0.623246, 0.587326], [0.131610, 0.216423], + [0.196995, 0.178758], [0.275698, 0.179852], [0.344479, 0.231733], [0.270791, 0.245099], + [0.192616, 0.244077], [0.655520, 0.231733], [0.724301, 0.179852], [0.803005, 0.178758], + [0.868389, 0.216423], [0.807383, 0.244077], [0.729208, 0.245099], [0.264022, 0.780233], + [0.350858, 0.745405], [0.438731, 0.727388], [0.500000, 0.742578], [0.561268, 0.727388], + [0.649141, 0.745405], [0.735977, 0.780233], [0.652032, 0.864805], [0.566594, 0.902192], + [0.500000, 0.909281], [0.433405, 0.902192], [0.347967, 0.864805], [0.300252, 0.784792], + [0.437969, 0.778746], [0.500000, 0.785343], [0.562030, 0.778746], [0.699747, 0.784792], + [0.563237, 0.824182], [0.500000, 0.831803], [0.436763, 0.824182]]), + LandmarkType.LM_3D_26: np.array([ + [4.056931, -11.432347, 1.636229], # 8 chin LL + [1.833492, -12.542305, 4.061275], # 7 chin L + [0.0, -12.901019, 4.070434], # 6 chin C + [-1.833492, -12.542305, 4.061275], # 5 chin R + [-4.056931, -11.432347, 1.636229], # 4 chin RR + [6.825897, 1.275284, 4.402142], # 33 L eyebrow L + [1.330353, 1.636816, 6.903745], # 29 L eyebrow R + [-1.330353, 1.636816, 6.903745], # 34 R eyebrow L + [-6.825897, 1.275284, 4.402142], # 38 R eyebrow R + [1.930245, -5.060977, 5.914376], # 54 nose LL + [0.746313, -5.136947, 6.263227], # 53 nose L + [0.0, -5.485328, 6.76343], # 52 nose C + [-0.746313, -5.136947, 6.263227], # 51 nose R + [-1.930245, -5.060977, 5.914376], # 50 nose RR + [5.311432, 0.0, 3.987654], # 13 L eye L + [1.78993, -0.091703, 4.413414], # 17 L eye R + [-1.78993, -0.091703, 4.413414], # 25 R eye L + [-5.311432, 0.0, 3.987654], # 21 R eye R + [2.774015, -7.566103, 5.048531], # 43 mouth L + [0.509714, -7.056507, 6.566167], # 42 mouth top L + [0.0, -7.131772, 6.704956], # 41 mouth top C + [-0.509714, -7.056507, 6.566167], # 40 mouth top R + [-2.774015, -7.566103, 5.048531], # 39 mouth R + [-0.589441, -8.443925, 6.109526], # 46 mouth bottom R + [0.0, -8.601736, 6.097667], # 45 mouth bottom C + [0.589441, -8.443925, 6.109526]])} # 44 mouth bottom L +"""dict[:class:`~LandmarkType, np.ndarray]: 'Mean' landmark points for various landmark types. Used +for aligning faces """ + +LANDMARK_PARTS: dict[LandmarkType, dict[str, tuple[int, int, bool]]] = { + LandmarkType.LM_2D_68: {"mouth_outer": (48, 60, True), + "mouth_inner": (60, 68, True), + "right_eyebrow": (17, 22, False), + "left_eyebrow": (22, 27, False), + "right_eye": (36, 42, True), + "left_eye": (42, 48, True), + "nose": (27, 36, False), + "jaw": (0, 17, False), + "chin": (8, 11, False)}, + LandmarkType.LM_2D_4: {"face": (0, 4, True)}} +"""dict[:class:`LandmarkType`, dict[str, tuple[int, int, bool]]: For each landmark type, stores +the (start index, end index, is polygon) information about each part of the face. """ diff --git a/lib/align/detected_face.py b/lib/align/detected_face.py index 15fbb5c..8ebea9a 100644 --- a/lib/align/detected_face.py +++ b/lib/align/detected_face.py @@ -15,7 +15,7 @@ from lib.image import encode_image, read_image from lib.utils import FaceswapError from .alignments import (Alignments, AlignmentFileDict, MaskAlignmentsFileDict, PNGHeaderAlignmentsDict, PNGHeaderDict, PNGHeaderSourceDict) -from . import AlignedFace, get_adjusted_center, get_centered_size +from . import AlignedFace, get_adjusted_center, get_centered_size, LANDMARK_PARTS if T.TYPE_CHECKING: from collections.abc import Callable @@ -231,12 +231,26 @@ class DetectedFace(): ------- :class:`numpy.ndarray` The generated landmarks mask for the selected area + + Raises + ------ + FaceSwapError + If the aligned face does not contain the correct landmarks to generate a landmark mask """ # TODO Face mask generation from landmarks logger.trace("area: %s, dilation: %s", area, dilation) # type:ignore[attr-defined] - areas = {"mouth": [slice(48, 60)], "eye": [slice(36, 42), slice(42, 48)]} - points = [self.aligned.landmarks[zone] - for zone in areas[area]] + + lm_type = self.aligned.landmark_type + if lm_type not in LANDMARK_PARTS: + raise FaceswapError(f"Landmark based masks cannot be created for {lm_type.name}") + + lm_parts = LANDMARK_PARTS[self.aligned.landmark_type] + mapped = {"mouth": ["mouth_outer"], "eye": ["right_eye", "left_eye"]} + if not all(part in lm_parts for parts in mapped.values() for part in parts): + raise FaceswapError(f"Landmark based masks cannot be created for {lm_type.name}") + + areas = {key: [slice(*lm_parts[v][:2]) for v in val]for key, val in mapped.items()} + points = [self.aligned.landmarks[zone] for zone in areas[area]] lmmask = LandmarksMask(points, storage_size=self.aligned.size, @@ -660,9 +674,9 @@ class Mask(): The mask that is to be added as output from :mod:`plugins.extract.mask`. It should be in the range 0.0 - 1.0 ideally with a ``dtype`` of ``float32`` """ - mask = (cv2.resize(mask, + mask = (cv2.resize(mask * 255.0, (self.stored_size, self.stored_size), - interpolation=cv2.INTER_AREA) * 255.0).astype("uint8") + interpolation=cv2.INTER_AREA)).astype("uint8") self._mask = compress(mask.tobytes()) def set_dilation(self, amount: float) -> None: @@ -903,7 +917,7 @@ class LandmarksMask(Mask): mask = np.zeros((self.stored_size, self.stored_size, 1), dtype="float32") for landmarks in self._points: lms = np.rint(landmarks).astype("int") - cv2.fillConvexPoly(mask, cv2.convexHull(lms), 1.0, lineType=cv2.LINE_AA) + cv2.fillConvexPoly(mask, cv2.convexHull(lms), [1.0], lineType=cv2.LINE_AA) if self._dilation[-1] is not None: self._dilate_mask(mask) if self._blur_kernel != 0 and self._blur_type is not None: diff --git a/lib/align/pose.py b/lib/align/pose.py new file mode 100644 index 0000000..fe186a3 --- /dev/null +++ b/lib/align/pose.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +""" Holds estimated pose information for a faceswap aligned face """ +from __future__ import annotations + +import logging +import typing as T + +import cv2 +import numpy as np + +from lib.logger import parse_class_init + +from .constants import _MEAN_FACE, LandmarkType + +logger = logging.getLogger(__name__) + +if T.TYPE_CHECKING: + from .constants import CenteringType + + +class PoseEstimate(): + """ Estimates pose from a generic 3D head model for the given 2D face landmarks. + + Parameters + ---------- + landmarks: :class:`numpy.ndarry` + The original 68 point landmarks aligned to 0.0 - 1.0 range + landmarks_type: :class:`~LandmarksType` + The type of landmarks that are generating this face + + References + ---------- + Head Pose Estimation using OpenCV and Dlib - https://www.learnopencv.com/tag/solvepnp/ + 3D Model points - http://aifi.isr.uc.pt/Downloads/OpenGL/glAnthropometric3DModel.cpp + """ + _logged_once = False + + def __init__(self, landmarks: np.ndarray, landmarks_type: LandmarkType) -> None: + logger.trace(parse_class_init(locals())) # type:ignore[attr-defined] + self._distortion_coefficients = np.zeros((4, 1)) # Assuming no lens distortion + self._xyz_2d: np.ndarray | None = None + + if landmarks_type != LandmarkType.LM_2D_68: + self._log_once("Pose estimation is not available for non-68 point landmarks. Pose and " + "offset data will all be returned as the incorrect value of '0'") + self._landmarks_type = landmarks_type + self._camera_matrix = self._get_camera_matrix() + self._rotation, self._translation = self._solve_pnp(landmarks) + self._offset = self._get_offset() + self._pitch_yaw_roll: tuple[float, float, float] = (0, 0, 0) + logger.trace("Initialized %s", self.__class__.__name__) # type:ignore[attr-defined] + + @property + def xyz_2d(self) -> np.ndarray: + """ :class:`numpy.ndarray` projected (x, y) coordinates for each x, y, z point at a + constant distance from adjusted center of the skull (0.5, 0.5) in the 2D space. """ + if self._xyz_2d is None: + xyz = cv2.projectPoints(np.array([[6., 0., -2.3], + [0., 6., -2.3], + [0., 0., 3.7]]).astype("float32"), + self._rotation, + self._translation, + self._camera_matrix, + self._distortion_coefficients)[0].squeeze() + self._xyz_2d = xyz - self._offset["head"] + return self._xyz_2d + + @property + def offset(self) -> dict[CenteringType, np.ndarray]: + """ dict: The amount to offset a standard 0.0 - 1.0 umeyama transformation matrix for a + from the center of the face (between the eyes) or center of the head (middle of skull) + rather than the nose area. """ + return self._offset + + @property + def pitch(self) -> float: + """ float: The pitch of the aligned face in eular angles """ + if not any(self._pitch_yaw_roll): + self._get_pitch_yaw_roll() + return self._pitch_yaw_roll[0] + + @property + def yaw(self) -> float: + """ float: The yaw of the aligned face in eular angles """ + if not any(self._pitch_yaw_roll): + self._get_pitch_yaw_roll() + return self._pitch_yaw_roll[1] + + @property + def roll(self) -> float: + """ float: The roll of the aligned face in eular angles """ + if not any(self._pitch_yaw_roll): + self._get_pitch_yaw_roll() + return self._pitch_yaw_roll[2] + + @classmethod + def _log_once(cls, message: str) -> None: + """ Log a warning about unsupported landmarks if a message has not already been logged """ + if cls._logged_once: + return + logger.warning(message) + cls._logged_once = True + + def _get_pitch_yaw_roll(self) -> None: + """ Obtain the yaw, roll and pitch from the :attr:`_rotation` in eular angles. """ + proj_matrix = np.zeros((3, 4), dtype="float32") + proj_matrix[:3, :3] = cv2.Rodrigues(self._rotation)[0] + euler = cv2.decomposeProjectionMatrix(proj_matrix)[-1] + self._pitch_yaw_roll = T.cast(tuple[float, float, float], tuple(euler.squeeze())) + logger.trace("yaw_pitch: %s", self._pitch_yaw_roll) # type:ignore[attr-defined] + + @classmethod + def _get_camera_matrix(cls) -> np.ndarray: + """ Obtain an estimate of the camera matrix based off the original frame dimensions. + + Returns + ------- + :class:`numpy.ndarray` + An estimated camera matrix + """ + focal_length = 4 + camera_matrix = np.array([[focal_length, 0, 0.5], + [0, focal_length, 0.5], + [0, 0, 1]], dtype="double") + logger.trace("camera_matrix: %s", camera_matrix) # type:ignore[attr-defined] + return camera_matrix + + def _solve_pnp(self, landmarks: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """ Solve the Perspective-n-Point for the given landmarks. + + Takes 2D landmarks in world space and estimates the rotation and translation vectors + in 3D space. + + Parameters + ---------- + landmarks: :class:`numpy.ndarry` + The original 68 point landmark co-ordinates relating to the original frame + + Returns + ------- + rotation: :class:`numpy.ndarray` + The solved rotation vector + translation: :class:`numpy.ndarray` + The solved translation vector + """ + if self._landmarks_type != LandmarkType.LM_2D_68: + points: np.ndarray = np.empty([]) + rotation = np.array([[0.0], [0.0], [0.0]]) + translation = rotation.copy() + else: + points = landmarks[[6, 7, 8, 9, 10, 17, 21, 22, 26, 31, 32, 33, 34, + 35, 36, 39, 42, 45, 48, 50, 51, 52, 54, 56, 57, 58]] + _, rotation, translation = cv2.solvePnP(_MEAN_FACE[LandmarkType.LM_3D_26], + points, + self._camera_matrix, + self._distortion_coefficients, + flags=cv2.SOLVEPNP_ITERATIVE) + logger.trace("points: %s, rotation: %s, translation: %s", # type:ignore[attr-defined] + points, rotation, translation) + return rotation, translation + + def _get_offset(self) -> dict[CenteringType, np.ndarray]: + """ Obtain the offset between the original center of the extracted face to the new center + of the head in 2D space. + + Returns + ------- + :class:`numpy.ndarray` + The x, y offset of the new center from the old center. + """ + offset: dict[CenteringType, np.ndarray] = {"legacy": np.array([0.0, 0.0])} + if self._landmarks_type != LandmarkType.LM_2D_68: + offset["face"] = np.array([0.0, 0.0]) + offset["head"] = np.array([0.0, 0.0]) + else: + points: dict[T.Literal["face", "head"], tuple[float, ...]] = {"head": (0.0, 0.0, -2.3), + "face": (0.0, -1.5, 4.2)} + for key, pnts in points.items(): + center = cv2.projectPoints(np.array([pnts]).astype("float32"), + self._rotation, + self._translation, + self._camera_matrix, + self._distortion_coefficients)[0].squeeze() + logger.trace("center %s: %s", key, center) # type:ignore[attr-defined] + offset[key] = center - np.array([0.5, 0.5]) + logger.trace("offset: %s", offset) # type:ignore[attr-defined] + return offset diff --git a/lib/cli/args_extract_convert.py b/lib/cli/args_extract_convert.py index ed58a53..61c60b9 100644 --- a/lib/cli/args_extract_convert.py +++ b/lib/cli/args_extract_convert.py @@ -140,7 +140,9 @@ class ExtractArgs(ExtractConvertArgs): "other GPU detectors but can often return more false positives." "\nL|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and " "fewer false positives than other GPU detectors, but is a lot more resource " - "intensive.")}) + "intensive." + "\nL|external: Import a face detection bounding box from a json file. (" + "configurable in Detect settings)")}) argument_list.append({ "opts": ("-A", "--aligner"), "action": Radio, @@ -152,7 +154,9 @@ class ExtractArgs(ExtractConvertArgs): "R|Aligner to use." "\nL|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, but " "less accurate. Only use this if not using a GPU and time is important." - "\nL|fan: Best aligner. Fast on GPU, slow on CPU.")}) + "\nL|fan: Best aligner. Fast on GPU, slow on CPU." + "\nL|external: Import 68 point 2D landmarks or an aligned bounding box from a " + "json file. (configurable in Align settings)")}) argument_list.append({ "opts": ("-M", "--masker"), "action": MultiOption, diff --git a/lib/image.py b/lib/image.py index 7a9d1f9..7d41a5a 100644 --- a/lib/image.py +++ b/lib/image.py @@ -1253,7 +1253,8 @@ class ImagesLoader(ImageIO): reader.close() def _dummy_video_framename(self, index): - """ Return a dummy filename for video files + """ Return a dummy filename for video files. The file name is made up of: + _. Parameters ---------- @@ -1268,8 +1269,8 @@ class ImagesLoader(ImageIO): Returns ------- str: A dummied filename for a video frame """ - vidname = os.path.splitext(os.path.basename(self.location))[0] - return "{}_{:06d}.png".format(vidname, index + 1) + vidname, ext = os.path.splitext(os.path.basename(self.location)) + return f"{vidname}_{index + 1:06d}{ext}" def _from_folder(self): """ Generator for loading images from a folder @@ -1565,6 +1566,7 @@ class ImagesSaver(ImageIO): with open(filename, "wb") as out_file: out_file.write(image) else: + assert isinstance(image, np.ndarray) cv2.imwrite(filename, image) logger.trace("Saved image: '%s'", filename) # type:ignore except Exception as err: # pylint:disable=broad-except diff --git a/lib/training/cache.py b/lib/training/cache.py index 9813199..8915f79 100644 --- a/lib/training/cache.py +++ b/lib/training/cache.py @@ -11,8 +11,7 @@ import cv2 import numpy as np from tqdm import tqdm -from lib.align import DetectedFace -from lib.align.aligned_face import CenteringType +from lib.align import CenteringType, DetectedFace, LandmarkType from lib.image import read_image_batch, read_image_meta_batch from lib.utils import FaceswapError @@ -280,6 +279,11 @@ class _Cache(): The list of full paths to the images to load the metadata from side: str `"a"` or `"b"`. The side of the model being cached. Used for info output + + Raises + ------ + FaceSwapError + If unsupported landmark type exists """ with self._lock: for filename, meta in tqdm(read_image_meta_batch(filenames), @@ -294,6 +298,13 @@ class _Cache(): # Version Check self._validate_version(meta, filename) detected_face = self._load_detected_face(filename, meta["alignments"]) + + aligned = detected_face.aligned + assert aligned is not None + if aligned.landmark_type != LandmarkType.LM_2D_68: + raise FaceswapError("68 Point facial Landmarks are required for Warp-to-" + f"landmarks. The face that failed was: '{filename}'") + self._cache[key] = detected_face self._partially_loaded.append(key) @@ -421,11 +432,14 @@ class _Cache(): return None if self._config["mask_type"] not in detected_face.mask: + exist_masks = list(detected_face.mask) + msg = "No masks exist for this face" + if exist_masks: + msg = f"The masks that exist for this face are: {exist_masks}" raise FaceswapError( f"You have selected the mask type '{self._config['mask_type']}' but at least one " "face does not contain the selected mask.\n" - f"The face that failed was: '{filename}'\n" - f"The masks that exist for this face are: {list(detected_face.mask)}") + f"The face that failed was: '{filename}'\n{msg}") mask = detected_face.mask[str(self._config["mask_type"])] assert isinstance(self._config["mask_dilation"], float) @@ -469,7 +483,12 @@ class _Cache(): assert isinstance(multiplier, int) if not self._config["penalized_mask_loss"] or multiplier <= 1: return None - mask = detected_face.get_landmark_mask(area, self._size // 16, 2.5) + try: + mask = detected_face.get_landmark_mask(area, self._size // 16, 2.5) + except FaceswapError as err: + logger.error(str(err)) + raise FaceswapError("Eye/Mouth multiplier masks could not be generated due to missing " + f"landmark data. The file that failed was: '{filename}'") from err logger.trace("Caching localized '%s' mask for: %s %s", # type: ignore area, filename, mask.shape) return mask diff --git a/locales/es/LC_MESSAGES/lib.cli.args_extract_convert.mo b/locales/es/LC_MESSAGES/lib.cli.args_extract_convert.mo index 70acb02db09c2848aed6891b43859a4977d3f793..5ccd0c2f2106fbffadd06ce06cbd8049936ce9e9 100644 GIT binary patch delta 1630 zcmb7@Z%kEX9LK*96`)9f`6oyo6BAGnE=I-*C9#A6OS4us<#_M8c<^!#oqMh_qwaG3 zb#A&Tt3_)K-!<8CclDxL%xU(*w(3P|wX9dQwbhH()~w9+IrosxjW<2xdq2S(4~Ux_mxExqQy`RI#nQTg7@CQ4{b}4lwqs~@~4>_nSk;>sgcmN)T0W2()E=pM)pmPfYJLXBvY>1U*O zvD8Q6>&v96Lg~j^=`#{vxkGxH`5h~y-kI>>{|@{L+2=b6MrgLux68lTk7nV z4iP^JLkqv&%(K9P2f01-uWgAwbf`xu-K4}A7iye0S{k})u*50U70_#TY7`}R|hFvo~mw`L#fWk zQ{0}sk4L3<$vc~tUVuR$v6cRW+hJcOs-?Fxyg!%FWd=^d&G_>j2mTDN!1H5KHGgtY z+ROamXQC(h9a0kd3zDWfbUO+!QIGCK)M37`LEg=37_MnWHHh-jGS1Kcd)x-sB7>Hr z)hK-XuoXwps#%u8r3eE=ZK?|W*XGlq@KuYJ%nmM`WmqeVX8R#lqJD~Aji?2MEry!% zz32y5uWKuaa9p^YZw>r5qXnoeXBX91XG{CW?Xkf2T`SdLdPYV)KQQg<&8X+Nfr)jS zl;tKyEPr3xc)qb*W2KxS*G`x{UdBy0?hu_Zlk~k2W0~Q!=bEIGvYSnP+;fx8P{z0R zq-^83rYl@$(smHADBYlK($8!_4?S=MSA7PoK6}L?y%nBSf{=X;Y_YbZenY*xj z<&xuWN1|+9Q)|q$#yVPJt6N%gKXm+U_FeY{PTmH~&l{0(11}vq(eEYbBy8i`N!z#G zxMLa5WLy)^SP9=_CgqGcft3i2N;$4gFH9%H^5c7*{hsOFzICAa_|t=RC9?@uL+;z1 zFBU#A$MN}vSGSIQh&yLtCGkJgfi! delta 1491 zcmYk6U2IfE6vzK9RVz|~7Ar_;r`2k=e6?K-wdJcK;zz4ujRvs5?e1;&Hn;cIy>~Iy z;06>S(Wt>7A_iFO71AcyKZ}l*sgiPxd61v3+#s!nQ}AQhe4}(fya0FN zS96ne7CWexMqu)0X)b&pz5q|a7`Duoew6Y$j53db+%3`yHh2yGgZ;@IbnMeL=-9pN zvJFndJRD%rQFtDvpf_Jyg#UTi4S%VXs-amRC1Bz<=>hx(>m_2;yYOqf(=n6*M#gTJ zE;8|1qqL?{db3G7hkdSD+Ryxv77k~l=5}sKcIuXKTln2d=JETvt8~MFRBo^~d#NJOSGtEnVOOT#x_e$JiSl?3dQT=>hWp z3`*w{6dVf_CoUgf69fmhto? zUj$6ND7_93Li;q0FO`a_72-bA=Fn4%YA%_Y4ec|{MW{_RBFi{mbqK*~F=E?tgjy08 zpJ|4^1Gx)Xge*mFMOGqrBX=U#+6t5e(spg&e{4nXMCKuPAk@3^)pYiaDR8wQWO1e~ zr02`K-pH!%DO<@hmt3DMWud8T^Fhu0msqpCJZ7JDfz|tRaX9Y8Udr+PNmF>Xs(x`{ zs%dRyVSh{O!u+=Wjhp&5Y#B07XS~RS0WN{-rXv%FX4o~6>$|DgO&f2-IL3EJoz$cm z57Vx1a36NPVAS}blg1;=TBkVUW?kbT<2gU}CVUj@9vLsNljX%Dp`UiM3g0=|ld4HB z@9H+4-94Sjr5zoGCB1){!l~!>RTj?dNH+Wz;9#-fhsKSFFXcu~)(cC~rMxh3vP{H| z2`5rsu@IQZW6lm0KQJjLJL&}K(By*B;i0iHrX7ax%Q%x}m)$KY&ql@#jPKZW6K>q# Vm~=VlFjD0BUfPi}mWyIH(!ZTZ1Z4mK diff --git a/locales/es/LC_MESSAGES/lib.cli.args_extract_convert.po b/locales/es/LC_MESSAGES/lib.cli.args_extract_convert.po index 290d476..4b2e299 100755 --- a/locales/es/LC_MESSAGES/lib.cli.args_extract_convert.po +++ b/locales/es/LC_MESSAGES/lib.cli.args_extract_convert.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-28 18:11+0000\n" -"PO-Revision-Date: 2024-03-28 18:13+0000\n" +"POT-Creation-Date: 2024-04-12 11:56+0100\n" +"PO-Revision-Date: 2024-04-12 12:02+0100\n" "Last-Translator: \n" "Language-Team: tokafondo\n" "Language: es\n" @@ -20,7 +20,7 @@ msgstr "" #: lib/cli/args_extract_convert.py:46 lib/cli/args_extract_convert.py:56 #: lib/cli/args_extract_convert.py:64 lib/cli/args_extract_convert.py:122 -#: lib/cli/args_extract_convert.py:479 lib/cli/args_extract_convert.py:488 +#: lib/cli/args_extract_convert.py:483 lib/cli/args_extract_convert.py:492 msgid "Data" msgstr "Datos" @@ -65,12 +65,12 @@ msgstr "" "varios videos y/o carpetas de imágenes de las que desea extraer. Las caras " "se enviarán a subcarpetas separadas en output_dir." -#: lib/cli/args_extract_convert.py:133 lib/cli/args_extract_convert.py:150 -#: lib/cli/args_extract_convert.py:163 lib/cli/args_extract_convert.py:202 -#: lib/cli/args_extract_convert.py:220 lib/cli/args_extract_convert.py:233 -#: lib/cli/args_extract_convert.py:243 lib/cli/args_extract_convert.py:253 -#: lib/cli/args_extract_convert.py:499 lib/cli/args_extract_convert.py:525 -#: lib/cli/args_extract_convert.py:564 +#: lib/cli/args_extract_convert.py:133 lib/cli/args_extract_convert.py:152 +#: lib/cli/args_extract_convert.py:167 lib/cli/args_extract_convert.py:206 +#: lib/cli/args_extract_convert.py:224 lib/cli/args_extract_convert.py:237 +#: lib/cli/args_extract_convert.py:247 lib/cli/args_extract_convert.py:257 +#: lib/cli/args_extract_convert.py:503 lib/cli/args_extract_convert.py:529 +#: lib/cli/args_extract_convert.py:568 msgid "Plugins" msgstr "Extensiones" @@ -84,7 +84,9 @@ msgid "" "than other GPU detectors but can often return more false positives.\n" "L|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and " "fewer false positives than other GPU detectors, but is a lot more resource " -"intensive." +"intensive.\n" +"L|external: Import a face detection bounding box from a json file. " +"(configurable in Detect settings)" msgstr "" "R|Detector de caras a usar. Algunos tienen ajustes configurables en '/config/" "extract.ini' o 'Ajustes > Configurar Extensiones de Extracción:\n" @@ -95,21 +97,28 @@ msgstr "" "positivos.\n" "L|s3fd: El mejor detector. Lento en la CPU, y más rápido en la GPU. Puede " "detectar más caras y tiene menos falsos positivos que otros detectores " -"basados en GPU, pero uso muchos más recursos." +"basados en GPU, pero uso muchos más recursos.\n" +"L|external: importe un cuadro de detección de detección de cara desde un " +"archivo JSON. (configurable en la configuración de detección)" -#: lib/cli/args_extract_convert.py:152 +#: lib/cli/args_extract_convert.py:154 msgid "" "R|Aligner to use.\n" "L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " "but less accurate. Only use this if not using a GPU and time is important.\n" -"L|fan: Best aligner. Fast on GPU, slow on CPU." +"L|fan: Best aligner. Fast on GPU, slow on CPU.\n" +"L|external: Import 68 point 2D landmarks or an aligned bounding box from a " +"json file. (configurable in Align settings)" msgstr "" "R|Alineador a usar.\n" "L|cv2-dnn: Detector que usa sólo la CPU. Más rápido, usa menos recursos, " "pero es menos preciso. Elegir este si necesita rapidez y no usar la GPU.\n" -"L|fan: El mejor alineador. Rápido en la GPU, y lento en la CPU." +"L|fan: El mejor alineador. Rápido en la GPU, y lento en la CPU.\n" +"L|external: importar 68 puntos 2D Modos de referencia o un cuadro " +"delimitador alineado de un archivo JSON. (configurable en la configuración " +"alineada)" -#: lib/cli/args_extract_convert.py:165 +#: lib/cli/args_extract_convert.py:169 msgid "" "R|Additional Masker(s) to use. The masks generated here will all take up GPU " "RAM. You can select none, one or multiple masks, but the extraction may take " @@ -179,7 +188,7 @@ msgstr "" "referencia y la máscara se extiende hacia arriba en la frente.\n" "(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" -#: lib/cli/args_extract_convert.py:204 +#: lib/cli/args_extract_convert.py:208 msgid "" "R|Performing normalization can help the aligner better align faces with " "difficult lighting conditions at an extraction speed cost. Different methods " @@ -202,7 +211,7 @@ msgstr "" "L|hist: Iguala los histogramas de los canales RGB.\n" "L|mean: Normalizar los colores de la cara a la media." -#: lib/cli/args_extract_convert.py:222 +#: lib/cli/args_extract_convert.py:226 msgid "" "The number of times to re-feed the detected face into the aligner. Each time " "the face is re-fed into the aligner the bounding box is adjusted by a small " @@ -219,7 +228,7 @@ msgstr "" "más veces se vuelva a introducir la cara en el alineador, menos " "microfluctuaciones se producirán, pero la extracción será más larga." -#: lib/cli/args_extract_convert.py:235 +#: lib/cli/args_extract_convert.py:239 msgid "" "Re-feed the initially found aligned face through the aligner. Can help " "produce better alignments for faces that are rotated beyond 45 degrees in " @@ -230,7 +239,7 @@ msgstr "" "se giran más de 45 grados en el marco o se encuentran en ángulos extremos. " "Ralentiza la extracción." -#: lib/cli/args_extract_convert.py:245 +#: lib/cli/args_extract_convert.py:249 msgid "" "If a face isn't found, rotate the images to try to find a face. Can find " "more faces at the cost of extraction speed. Pass in a single number to use " @@ -242,7 +251,7 @@ msgstr "" "un solo número para usar incrementos de ese tamaño hasta 360, o pase una " "lista de números para enumerar exactamente qué ángulos comprobar." -#: lib/cli/args_extract_convert.py:255 +#: lib/cli/args_extract_convert.py:259 msgid "" "Obtain and store face identity encodings from VGGFace2. Slows down extract a " "little, but will save time if using 'sort by face'" @@ -250,15 +259,15 @@ msgstr "" "Obtenga y almacene codificaciones de identidad facial de VGGFace2. Ralentiza " "un poco la extracción, pero ahorrará tiempo si usa 'sort by face'" -#: lib/cli/args_extract_convert.py:265 lib/cli/args_extract_convert.py:276 -#: lib/cli/args_extract_convert.py:289 lib/cli/args_extract_convert.py:303 -#: lib/cli/args_extract_convert.py:610 lib/cli/args_extract_convert.py:619 -#: lib/cli/args_extract_convert.py:634 lib/cli/args_extract_convert.py:647 -#: lib/cli/args_extract_convert.py:661 +#: lib/cli/args_extract_convert.py:269 lib/cli/args_extract_convert.py:280 +#: lib/cli/args_extract_convert.py:293 lib/cli/args_extract_convert.py:307 +#: lib/cli/args_extract_convert.py:614 lib/cli/args_extract_convert.py:623 +#: lib/cli/args_extract_convert.py:638 lib/cli/args_extract_convert.py:651 +#: lib/cli/args_extract_convert.py:665 msgid "Face Processing" msgstr "Proceso de Caras" -#: lib/cli/args_extract_convert.py:267 +#: lib/cli/args_extract_convert.py:271 msgid "" "Filters out faces detected below this size. Length, in pixels across the " "diagonal of the bounding box. Set to 0 for off" @@ -267,7 +276,7 @@ msgstr "" "a lo largo de la diagonal del cuadro delimitador. Establecer a 0 para " "desactivar" -#: lib/cli/args_extract_convert.py:278 +#: lib/cli/args_extract_convert.py:282 msgid "" "Optionally filter out people who you do not wish to extract by passing in " "images of those people. Should be a small variety of images at different " @@ -280,7 +289,7 @@ msgstr "" "contenga las imágenes requeridas o múltiples archivos de imágenes, separados " "por espacios." -#: lib/cli/args_extract_convert.py:291 +#: lib/cli/args_extract_convert.py:295 msgid "" "Optionally select people you wish to extract by passing in images of that " "person. Should be a small variety of images at different angles and in " @@ -293,7 +302,7 @@ msgstr "" "contenga las imágenes requeridas o múltiples archivos de imágenes, separados " "por espacios." -#: lib/cli/args_extract_convert.py:305 +#: lib/cli/args_extract_convert.py:309 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Higher values are stricter." @@ -301,12 +310,12 @@ msgstr "" "Para usar con los archivos nfilter/filter opcionales. Umbral para el " "reconocimiento facial positivo. Los valores más altos son más estrictos." -#: lib/cli/args_extract_convert.py:314 lib/cli/args_extract_convert.py:327 -#: lib/cli/args_extract_convert.py:340 lib/cli/args_extract_convert.py:352 +#: lib/cli/args_extract_convert.py:318 lib/cli/args_extract_convert.py:331 +#: lib/cli/args_extract_convert.py:344 lib/cli/args_extract_convert.py:356 msgid "output" msgstr "salida" -#: lib/cli/args_extract_convert.py:316 +#: lib/cli/args_extract_convert.py:320 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" @@ -316,7 +325,7 @@ msgstr "" "pretende entrenar admite el tamaño deseado. Esto sólo tendrá que ser " "cambiado para los modelos de alta resolución." -#: lib/cli/args_extract_convert.py:329 +#: lib/cli/args_extract_convert.py:333 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " @@ -326,7 +335,7 @@ msgstr "" "extraer las caras. Por ejemplo, un valor de 1 extraerá las caras de cada " "fotograma, un valor de 10 extraerá las caras de cada 10 fotogramas." -#: lib/cli/args_extract_convert.py:342 +#: lib/cli/args_extract_convert.py:346 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -342,20 +351,19 @@ msgstr "" "ADVERTENCIA: No interrumpa el script al escribir el archivo porque podría " "corromperse. Poner a 0 para desactivar" -#: lib/cli/args_extract_convert.py:353 +#: lib/cli/args_extract_convert.py:357 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "" "Dibujar puntos de referencia en las caras de salida para fines de depuración." -#: lib/cli/args_extract_convert.py:359 lib/cli/args_extract_convert.py:369 -#: lib/cli/args_extract_convert.py:377 lib/cli/args_extract_convert.py:384 -#: lib/cli/args_extract_convert.py:674 lib/cli/args_extract_convert.py:686 -#: lib/cli/args_extract_convert.py:695 lib/cli/args_extract_convert.py:716 -#: lib/cli/args_extract_convert.py:722 +#: lib/cli/args_extract_convert.py:363 lib/cli/args_extract_convert.py:373 +#: lib/cli/args_extract_convert.py:381 lib/cli/args_extract_convert.py:388 +#: lib/cli/args_extract_convert.py:678 lib/cli/args_extract_convert.py:691 +#: lib/cli/args_extract_convert.py:712 lib/cli/args_extract_convert.py:718 msgid "settings" msgstr "ajustes" -#: lib/cli/args_extract_convert.py:361 +#: lib/cli/args_extract_convert.py:365 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " "process separately (one after the other) rather than all at the same time. " @@ -365,7 +373,7 @@ msgstr "" "extracción por separado (una tras otra) en lugar de hacerlo todo al mismo " "tiempo. Útil si la VRAM es escasa." -#: lib/cli/args_extract_convert.py:371 +#: lib/cli/args_extract_convert.py:375 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" @@ -373,19 +381,19 @@ msgstr "" "Omite los fotogramas que ya han sido extraídos y que existen en el archivo " "de alineaciones" -#: lib/cli/args_extract_convert.py:378 +#: lib/cli/args_extract_convert.py:382 msgid "Skip frames that already have detected faces in the alignments file" msgstr "" "Omitir los fotogramas que ya tienen caras detectadas en el archivo de " "alineaciones" -#: lib/cli/args_extract_convert.py:385 +#: lib/cli/args_extract_convert.py:389 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" "No guardar las caras detectadas en el disco. Crear sólo un archivo de " "alineaciones" -#: lib/cli/args_extract_convert.py:459 +#: lib/cli/args_extract_convert.py:463 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" @@ -395,7 +403,7 @@ msgstr "" "Los plugins de conversión pueden ser configurados en el menú " "\"Configuración\"" -#: lib/cli/args_extract_convert.py:481 +#: lib/cli/args_extract_convert.py:485 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " @@ -405,7 +413,7 @@ msgstr "" "original del que se extrajeron los fotogramas de origen (para extraer los " "fps y el audio)." -#: lib/cli/args_extract_convert.py:490 +#: lib/cli/args_extract_convert.py:494 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." @@ -413,7 +421,7 @@ msgstr "" "Directorio del modelo. El directorio que contiene el modelo entrenado que " "desea utilizar para la conversión." -#: lib/cli/args_extract_convert.py:501 +#: lib/cli/args_extract_convert.py:505 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -453,7 +461,7 @@ msgstr "" "colores. Generalmente no da resultados muy satisfactorios.\n" "L|none: No realice el ajuste de color." -#: lib/cli/args_extract_convert.py:527 +#: lib/cli/args_extract_convert.py:531 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" @@ -529,7 +537,7 @@ msgstr "" "L|predicted: Si la opción 'Learn Mask' se habilitó durante el entrenamiento, " "esto usará la máscara que fue creada por el modelo entrenado." -#: lib/cli/args_extract_convert.py:566 +#: lib/cli/args_extract_convert.py:570 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -562,12 +570,12 @@ msgstr "" "L|pillow: [images] Más lento que opencv, pero tiene más opciones y soporta " "más formatos." -#: lib/cli/args_extract_convert.py:587 lib/cli/args_extract_convert.py:596 -#: lib/cli/args_extract_convert.py:707 +#: lib/cli/args_extract_convert.py:591 lib/cli/args_extract_convert.py:600 +#: lib/cli/args_extract_convert.py:703 msgid "Frame Processing" msgstr "Proceso de fotogramas" -#: lib/cli/args_extract_convert.py:589 +#: lib/cli/args_extract_convert.py:593 #, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " @@ -577,7 +585,7 @@ msgstr "" "a los fotogramas a las dimensiones de origen. 50%% a la mitad de tamaño. " "200%% al doble de tamaño" -#: lib/cli/args_extract_convert.py:598 +#: lib/cli/args_extract_convert.py:602 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -591,7 +599,7 @@ msgstr "" "imágenes, ¡los nombres de los archivos deben terminar con el número de " "fotograma!" -#: lib/cli/args_extract_convert.py:612 +#: lib/cli/args_extract_convert.py:616 msgid "" "Scale the swapped face by this percentage. Positive values will enlarge the " "face, Negative values will shrink the face." @@ -599,7 +607,7 @@ msgstr "" "Escale la cara intercambiada según este porcentaje. Los valores positivos " "agrandarán la cara, los valores negativos la reducirán." -#: lib/cli/args_extract_convert.py:621 +#: lib/cli/args_extract_convert.py:625 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -615,7 +623,7 @@ msgstr "" "especificada. Si se deja en blanco, se convertirán todas las caras que " "existan en el archivo de alineaciones." -#: lib/cli/args_extract_convert.py:636 +#: lib/cli/args_extract_convert.py:640 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -629,7 +637,7 @@ msgstr "" "uso del filtro de caras disminuirá significativamente la velocidad de " "extracción y no se puede garantizar su precisión." -#: lib/cli/args_extract_convert.py:649 +#: lib/cli/args_extract_convert.py:653 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -643,7 +651,7 @@ msgstr "" "del filtro facial disminuirá significativamente la velocidad de extracción y " "no se puede garantizar su precisión." -#: lib/cli/args_extract_convert.py:663 +#: lib/cli/args_extract_convert.py:667 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -655,7 +663,7 @@ msgstr "" "NB: El uso del filtro facial disminuirá significativamente la velocidad de " "extracción y no se puede garantizar su precisión." -#: lib/cli/args_extract_convert.py:676 +#: lib/cli/args_extract_convert.py:680 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -672,15 +680,7 @@ msgstr "" "procesos que los disponibles en su sistema. Si 'singleprocess' está " "habilitado, este ajuste será ignorado." -#: lib/cli/args_extract_convert.py:688 -msgid "" -"[LEGACY] This only needs to be selected if a legacy model is being loaded or " -"if there are multiple models in the model folder" -msgstr "" -"[LEGACY] Sólo es necesario seleccionar esta opción si se está cargando un " -"modelo heredado si hay varios modelos en la carpeta de modelos" - -#: lib/cli/args_extract_convert.py:697 +#: lib/cli/args_extract_convert.py:693 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -695,7 +695,7 @@ msgstr "" "de baja calidad. Si se encuentra un archivo de alineaciones, esta opción " "será ignorada." -#: lib/cli/args_extract_convert.py:709 +#: lib/cli/args_extract_convert.py:705 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." @@ -703,11 +703,18 @@ msgstr "" "Cuando se usa con --frame-ranges, la salida incluye los fotogramas no " "procesados en vez de descartarlos." -#: lib/cli/args_extract_convert.py:717 +#: lib/cli/args_extract_convert.py:713 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" "Intercambiar el modelo. En vez de convertir de A a B, convierte de B a A" -#: lib/cli/args_extract_convert.py:723 +#: lib/cli/args_extract_convert.py:719 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "Desactiva el multiproceso. Es más lento, pero usa menos recursos." + +#~ msgid "" +#~ "[LEGACY] This only needs to be selected if a legacy model is being loaded " +#~ "or if there are multiple models in the model folder" +#~ msgstr "" +#~ "[LEGACY] Sólo es necesario seleccionar esta opción si se está cargando un " +#~ "modelo heredado si hay varios modelos en la carpeta de modelos" diff --git a/locales/es/LC_MESSAGES/tools.alignments.cli.mo b/locales/es/LC_MESSAGES/tools.alignments.cli.mo index 9aa60ed0c98cded8c8f14625010b9ae422c6b8c9..f6786ef695682f93b758135d47b8af8579416fc5 100644 GIT binary patch delta 1242 zcmY*Xzi$*r6n;*?NsJH?U=$1_uZVIW?2FGJBrLhGA|iqOqrj1+h!oGeN#N{IwY12p$1xC%v@R1uxYydgHuNPf_3MNf zwfd#cpX2!q&oG`eYWhJI8tMN&w0Yo6;e2dN^OZa4z;R7+=4@0p26n64vmYPtMaRN7 zgR{J6-vaM}s@`8(MnmZD1)dh8q#yW0r zS7p)p!0Xx=wk{^B+GeKAK^J7wgsK`g(Z@jJ99$iZNKk4ktGq;fT~8!q4+;CzvkqKy z#`hJ;ZlNUf`JZ!>XVEp6C@FBi&AN_jRb%Hh==0ghah^QKQtKUE!A7k{=w0hCwkx4I zGIy^5<$Qm1_}3$cFHW?kxHUE1x_ExF)qgVj!|+!hI4AJI46M_+<6LdejGgWYmzKq6 zYr59yOcs#C8(E;(%HP`TbSrS?1bv4Z%UeIaUNBzNNs5!pDtU8pk$Cl~`E!etOceYEUgDG^(jCOVQ*dmxGo`lSy>*A;k^a*mkH3T2_Z1|_|DyVT0(0wD|=2)^l>{WquPkG=)=CV3A4 delta 326 zcmXBPze|Ea9LMqZ6XuUhD#adas4xji9yuRLP;kgah}@DuY7>N@Xw}8~1B$@K{szZq zYiTu?hG_0DNE#daoF3f0?tAyWyYD@8rkBs1K`{d9WjJu4u?lw~xJ^J4_}+j|=_NHR zfs+E?PSc4$>1$kSS)1rnj diff --git a/locales/es/LC_MESSAGES/tools.alignments.cli.po b/locales/es/LC_MESSAGES/tools.alignments.cli.po index 8560969..6f116c4 100644 --- a/locales/es/LC_MESSAGES/tools.alignments.cli.po +++ b/locales/es/LC_MESSAGES/tools.alignments.cli.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: faceswap.spanish\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-28 23:49+0000\n" -"PO-Revision-Date: 2024-03-29 00:02+0000\n" +"POT-Creation-Date: 2024-04-12 12:10+0100\n" +"PO-Revision-Date: 2024-04-12 12:14+0100\n" "Last-Translator: \n" "Language-Team: tokafondo\n" "Language: es_ES\n" @@ -65,17 +65,23 @@ msgstr "" msgid " Use the output option (-o) to process results." msgstr " Usar la opción de salida (-o) para procesar los resultados." -#: tools/alignments/cli.py:57 tools/alignments/cli.py:97 +#: tools/alignments/cli.py:58 tools/alignments/cli.py:104 msgid "processing" msgstr "proceso" -#: tools/alignments/cli.py:60 +#: tools/alignments/cli.py:61 #, python-brace-format msgid "" "R|Choose which action you want to perform. NB: All actions require an " "alignments file (-a) to be passed in.\n" "L|'draw': Draw landmarks on frames in the selected folder/video. A subfolder " "will be created within the frames folder to hold the output.{0}\n" +"L|'export': Export the contents of an alignments file to a json file. Can be " +"used for editing alignment information in external tools and then re-" +"importing by using Faceswap's Extract 'Import' plugins. Note: masks and " +"identity vectors will not be included in the exported file, so will be re-" +"generated when the json file is imported back into Faceswap. All data is " +"exported with the origin (0, 0) at the top left of the canvas.\n" "L|'extract': Re-extract faces from the source frames/video based on " "alignment data. This is a lot quicker than re-detecting faces. Can pass in " "the '-een' (--extract-every-n) parameter to only extract every nth frame." @@ -110,6 +116,14 @@ msgstr "" "L|'draw': Dibuja puntos de referencia en los fotogramas de la carpeta o " "vídeo seleccionado. Se creará una subcarpeta dentro de la carpeta de " "fotogramas para guardar el resultado.{0}\n" +"L|'export': Exportar el contenido de un archivo de alineaciones a un archivo " +"JSON. Se puede utilizar para editar información de alineación en " +"herramientas externas y luego volver a importar mediante el uso de " +"complementos de 'import' de extracto de Faceswap. Nota: Las máscaras y los " +"vectores de identidad no se incluirán en el archivo exportado, por lo que se " +"volverán a generar cuando el archivo JSON se importe a FacesWap. Todos los " +"datos se exportan con el origen (0, 0) en la parte superior izquierda del " +"lienzo.\n" "L|'extract': Reextrae las caras de los fotogramas o vídeos de origen " "basándose en los datos de alineación. Esto es mucho más rápido que volver a " "detectar las caras. Se puede pasar el parámetro '-een' (--extract-every-n) " @@ -142,7 +156,7 @@ msgstr "" "L|'spatial': Realiza un filtrado espacial y temporal para suavizar las " "alineaciones (¡EXPERIMENTAL!)" -#: tools/alignments/cli.py:100 +#: tools/alignments/cli.py:107 msgid "" "R|How to output discovered items ('faces' and 'frames' only):\n" "L|'console': Print the list of frames to the screen. (DEFAULT)\n" @@ -158,12 +172,12 @@ msgstr "" "L|'move': Mueve los elementos descubiertos a una subcarpeta dentro del " "directorio de origen." -#: tools/alignments/cli.py:111 tools/alignments/cli.py:134 -#: tools/alignments/cli.py:141 +#: tools/alignments/cli.py:118 tools/alignments/cli.py:141 +#: tools/alignments/cli.py:148 msgid "data" msgstr "datos" -#: tools/alignments/cli.py:118 +#: tools/alignments/cli.py:125 msgid "" "Full path to the alignments file to be processed. If you have input a " "'frames_dir' and don't provide this option, the process will try to find the " @@ -177,13 +191,13 @@ msgstr "" "requieren un archivo de alineaciones con la excepción de 'from-faces' cuando " "el archivo de alineaciones se generará en la carpeta de caras especificada." -#: tools/alignments/cli.py:135 +#: tools/alignments/cli.py:142 msgid "Directory containing source frames that faces were extracted from." msgstr "" "Directorio que contiene los fotogramas de origen de los que se extrajeron " "las caras." -#: tools/alignments/cli.py:143 +#: tools/alignments/cli.py:150 msgid "" "R|Run the aligmnents tool on multiple sources. The following jobs support " "batch mode:\n" @@ -226,12 +240,12 @@ msgstr "" "El archivo de alineaciones debe existir en la ubicación predeterminada. Para " "todos los demás trabajos, esta opción se ignora." -#: tools/alignments/cli.py:169 tools/alignments/cli.py:181 -#: tools/alignments/cli.py:191 +#: tools/alignments/cli.py:176 tools/alignments/cli.py:188 +#: tools/alignments/cli.py:198 msgid "extract" msgstr "extracción" -#: tools/alignments/cli.py:171 +#: tools/alignments/cli.py:178 msgid "" "[Extract only] Extract every 'nth' frame. This option will skip frames when " "extracting faces. For example a value of 1 will extract faces from every " @@ -242,11 +256,11 @@ msgstr "" "caras de cada fotograma, un valor de 10 extraerá las caras de cada 10 " "fotogramas." -#: tools/alignments/cli.py:182 +#: tools/alignments/cli.py:189 msgid "[Extract only] The output size of extracted faces." msgstr "[Sólo extracción] El tamaño de salida de las caras extraídas." -#: tools/alignments/cli.py:193 +#: tools/alignments/cli.py:200 msgid "" "[Extract only] Only extract faces that have been resized by this percent or " "more to meet the specified extract size (`-sz`, `--size`). Useful for " diff --git a/locales/kr/LC_MESSAGES/lib.cli.args_extract_convert.mo b/locales/kr/LC_MESSAGES/lib.cli.args_extract_convert.mo index 93c9b7658acf51f3b443805757fb09f187f8b5cb..1f0c43722c48ec666523c447dd823fe02f1d811e 100644 GIT binary patch delta 1661 zcmb7^Urby@6o(I#R*Fz1q<5N`l7YnvUhRoa<}eYC^oBG zbfW}Fy9sP1>CzAcc@QFXrQv}FV@OO|wDH0CVA?l%;NFe#$*PI*yX-cmiN-kLcfUC^ zXU@!=IZO1nl<$8}3Eo+k7772HACdKNKnqwd6WF!8Qa5wyVok%*A9FbDEI#*;b=8p9u55Nw1(~k5m zohC*a^F$8eI9Mn$k}C33k;np$-J3-|Wd7I|kzbg~#IDHD)vN(>g_Hw&lW zk56+e#`~TXc?DjAcVHYo2N(8`Is7s)a^P3blTpThsN>-9b{N@aiUKnWS&@jD2#0lSkb}fC=5u|YFlv0|5&<{%qnAs`vEgRRI zOH}j90700)!Dkgb@J@ow5I5P19zoQul%Pk^T4ZO@Zn-EQQASdQ3J~QZh3vol^VkHR zKnfM3?a1E9ZuZ;T?Xd(T18zk2+iynM=w8dDL*Yvi+K?Q~Ns?cztWWlBY(q^Hy_6w} zc)8jC2g;Lu8)Wsqwgrm>30YolrF<*U8k7~&(n|7!8HWN|r=j_Dho@FGw4L$#3{|sR zo$NP`aiA9QPQni0NghRO;Et+!LUWS{% z|98jb_{QH`#%>mG+t9DqzMQqQysBDNRo7NlZm+D0-Ku@B`2I9B9(}P{#ryiKp;_yr zuoa5h8Hrzq^)hn^j9w=dIxxYjE1^?KXQyNl?<;3XRh1|Mh5Y?C@)OEB@;s)}`5{=`RQuw#Luj z+s2%VSUscvvdwb*KRILt>9Q3cqt#I!UL3k(V>2 zQ6?_Tm)0gr9of9}<$5=UkHW<( zrAqiJbT{x7d>5uXAf1n};CpmFhHF+sCaxDt^{|<|z72neWLJR_P6WenKK|Ws5cZZz zWL1@GrRU%a@EZIaJ_I|~kwJV{VLEJin9MSMtc*;OT^Y9ZDa?ll;V7Jp(c&2HtEt~0 zRT1xo8{khHrEZv0E`87Zmyh9v`yY?Ls$1|B@#!ZdKYaR0Hi!QbY=YA&*cfcBB>(kv zzIlq2V_f-6yjU9GP2y%a!jXNOr7V1_D1Z!7zWGJzH|BjWNtqmdYqhjGnH2jtEmeBv z6;h3_c&Bs`->ycf9TtZqZqa4vg$u)xcu~C{me{m@f!%QVYZMF%ehaVi;9GCRtNQCV zrMH-G-6idVDFl;XM}nOYd#q8vnvQbfp{R55(Zs$6YEzV%mhybfL~K@B$Zg9IwG?}& zZfsBiT8QSLCFo8>-RWMm08O-fI(g{+iGlxd5&c5szS6mfdRN3Ps9A^tS1#hvu{N8l zTN@LNtm?i*Kw-z)JqfySvyh#L`JiI|C2q*a5^Gbrrn|kea41;s3HfR~et(mRKA)PA z6}^$YE;+h4chT&~woMz$%hqqLGSAle8ci^OC16?JMiUB}?Ure@{8mlK@)}>Q@fg3g z!&B2_>Vsa(Z?JE-e1RRtAM|)}1RGpasLpDzj0e?+{UP5@KONWJXnX;8GI4Wl(C@Vx z6g>P{$(FRDWh;tJVR1>}swD*l(X`U$oaln8%#`Rr^@(}^ZP4i*9y_3zr8eQjey zgHC&ovCp2i4_t71Ta5itkA1kuIQ<{EzM(<8xjQjy#t#nL2aY&hhm6xTV1GF9?_I;j zIoW3KJ7UJpU2={O;;>KcH%?36_?}+-P`7h**y)^m(TsPVvD@41_I^3%d+feX 추출 플러" "그인 설정'에서 설정이 가능합니다:\n" @@ -93,21 +95,27 @@ msgstr "" "니다.\n" "L|s3fd: 가장 좋은 감지기. CPU에선 느리고 GPU에선 빠릅니다. 다른 GPU 감지기들" "보다 더 많은 얼굴들을 감지할 수 있고 과 더 적은 false positives를 돌려주지만 " -"자원을 굉장히 많이 사용합니다." +"자원을 굉장히 많이 사용합니다.\n" +"L|external: JSON 파일에서 얼굴 감지 경계 박스를 가져옵니다. (설정 감지에서 구" +"성 가능)" -#: lib/cli/args_extract_convert.py:152 +#: lib/cli/args_extract_convert.py:154 msgid "" "R|Aligner to use.\n" "L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " "but less accurate. Only use this if not using a GPU and time is important.\n" -"L|fan: Best aligner. Fast on GPU, slow on CPU." +"L|fan: Best aligner. Fast on GPU, slow on CPU.\n" +"L|external: Import 68 point 2D landmarks or an aligned bounding box from a " +"json file. (configurable in Align settings)" msgstr "" "R|사용할 Aligner.\n" "L|cv2-dnn: CPU만을 사용하는 특징점 감지기. 빠르고 자원을 덜 사용하지만 부정확" "합니다. GPU를 사용하지 않고 시간이 중요할 때에만 사용하세요.\n" -"L|fan: 가장 좋은 aligner. GPU에선 빠르고 CPU에선 느립니다." +"L|fan: 가장 좋은 aligner. GPU에선 빠르고 CPU에선 느립니다.\n" +"L|external: JSON 파일에서 68 포인트 2D 랜드 마크 또는 정렬 된 경계 상자를 가" +"져옵니다. (정렬 설정에서 구성 가능)" -#: lib/cli/args_extract_convert.py:165 +#: lib/cli/args_extract_convert.py:169 msgid "" "R|Additional Masker(s) to use. The masks generated here will all take up GPU " "RAM. You can select none, one or multiple masks, but the extraction may take " @@ -168,7 +176,7 @@ msgstr "" "로 뻗어 있습ㄴ다.\n" "(예: '-M unet-dfl vgg-clear', '--masker vgg-obstructed')" -#: lib/cli/args_extract_convert.py:204 +#: lib/cli/args_extract_convert.py:208 msgid "" "R|Performing normalization can help the aligner better align faces with " "difficult lighting conditions at an extraction speed cost. Different methods " @@ -189,7 +197,7 @@ msgstr "" "L|hist: RGB 채널의 히스토그램을 동일하게 합니다.\n" "L|mean: 얼굴 색상을 평균으로 정규화합니다." -#: lib/cli/args_extract_convert.py:222 +#: lib/cli/args_extract_convert.py:226 msgid "" "The number of times to re-feed the detected face into the aligner. Each time " "the face is re-fed into the aligner the bounding box is adjusted by a small " @@ -204,7 +212,7 @@ msgstr "" "다. 얼굴이 aligner에 다시 공급되는 횟수가 많을수록 micro-jitter 적게 발생하지" "만 추출에 더 오랜 시간이 걸립니다." -#: lib/cli/args_extract_convert.py:235 +#: lib/cli/args_extract_convert.py:239 msgid "" "Re-feed the initially found aligned face through the aligner. Can help " "produce better alignments for faces that are rotated beyond 45 degrees in " @@ -214,7 +222,7 @@ msgstr "" "회전하거나 극단적인 각도에 있는 얼굴을 더 잘 정렬할 수 있습니다. 추출 속도가 " "느려집니다." -#: lib/cli/args_extract_convert.py:245 +#: lib/cli/args_extract_convert.py:249 msgid "" "If a face isn't found, rotate the images to try to find a face. Can find " "more faces at the cost of extraction speed. Pass in a single number to use " @@ -225,7 +233,7 @@ msgstr "" "면서 더 많은 얼굴을 찾을 수 있습니다. 단일 숫자를 입력하여 해당 크기의 증분" "을 360까지 사용하거나 숫자 목록을 입력하여 확인할 각도를 정확하게 열거합니다." -#: lib/cli/args_extract_convert.py:255 +#: lib/cli/args_extract_convert.py:259 msgid "" "Obtain and store face identity encodings from VGGFace2. Slows down extract a " "little, but will save time if using 'sort by face'" @@ -233,15 +241,15 @@ msgstr "" "VGGFace2에서 얼굴 식별 인코딩을 가져와 저장합니다. 추출 속도를 약간 늦추지만 " "'얼굴별로 정렬'을 사용하면 시간을 절약할 수 있습니다." -#: lib/cli/args_extract_convert.py:265 lib/cli/args_extract_convert.py:276 -#: lib/cli/args_extract_convert.py:289 lib/cli/args_extract_convert.py:303 -#: lib/cli/args_extract_convert.py:610 lib/cli/args_extract_convert.py:619 -#: lib/cli/args_extract_convert.py:634 lib/cli/args_extract_convert.py:647 -#: lib/cli/args_extract_convert.py:661 +#: lib/cli/args_extract_convert.py:269 lib/cli/args_extract_convert.py:280 +#: lib/cli/args_extract_convert.py:293 lib/cli/args_extract_convert.py:307 +#: lib/cli/args_extract_convert.py:614 lib/cli/args_extract_convert.py:623 +#: lib/cli/args_extract_convert.py:638 lib/cli/args_extract_convert.py:651 +#: lib/cli/args_extract_convert.py:665 msgid "Face Processing" msgstr "얼굴 처리" -#: lib/cli/args_extract_convert.py:267 +#: lib/cli/args_extract_convert.py:271 msgid "" "Filters out faces detected below this size. Length, in pixels across the " "diagonal of the bounding box. Set to 0 for off" @@ -249,7 +257,7 @@ msgstr "" "이 크기 미만으로 탐지된 얼굴을 필터링합니다. 길이, 경계 상자의 대각선에 걸친 " "픽셀 단위입니다. 0으로 설정하면 꺼집니다" -#: lib/cli/args_extract_convert.py:278 +#: lib/cli/args_extract_convert.py:282 msgid "" "Optionally filter out people who you do not wish to extract by passing in " "images of those people. Should be a small variety of images at different " @@ -261,7 +269,7 @@ msgstr "" "지들 또는 공백으로 구분된 여러 이미지 파일이 들어 있는 폴더를 선택할 수 있습" "니다." -#: lib/cli/args_extract_convert.py:291 +#: lib/cli/args_extract_convert.py:295 msgid "" "Optionally select people you wish to extract by passing in images of that " "person. Should be a small variety of images at different angles and in " @@ -272,7 +280,7 @@ msgstr "" "와 조건이 다른 작은 다양한 이미지여야 합니다. 추출할 때 필요한 이미지들 또는 " "공백으로 구분된 여러 이미지 파일이 들어 있는 폴더를 선택할 수 있습니다." -#: lib/cli/args_extract_convert.py:305 +#: lib/cli/args_extract_convert.py:309 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Higher values are stricter." @@ -280,12 +288,12 @@ msgstr "" "옵션인 nfilter/filter 파일과 함께 사용합니다. 긍정적인 얼굴 인식을 위한 임계" "값. 값이 높을수록 엄격합니다." -#: lib/cli/args_extract_convert.py:314 lib/cli/args_extract_convert.py:327 -#: lib/cli/args_extract_convert.py:340 lib/cli/args_extract_convert.py:352 +#: lib/cli/args_extract_convert.py:318 lib/cli/args_extract_convert.py:331 +#: lib/cli/args_extract_convert.py:344 lib/cli/args_extract_convert.py:356 msgid "output" msgstr "출력" -#: lib/cli/args_extract_convert.py:316 +#: lib/cli/args_extract_convert.py:320 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" @@ -294,7 +302,7 @@ msgstr "" "추출된 얼굴의 출력 크기입니다. 훈련하려는 모델이 필요한 크기를 지원하는지 꼭 " "확인하세요. 이것은 고해상도 모델에 대해서만 변경하면 됩니다." -#: lib/cli/args_extract_convert.py:329 +#: lib/cli/args_extract_convert.py:333 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " @@ -304,7 +312,7 @@ msgstr "" "설정합니다. 예를 들어, 값이 1이면 모든 프레임에서 얼굴이 추출되고, 값이 10이" "면 모든 10번째 프레임에서 얼굴이 추출됩니다." -#: lib/cli/args_extract_convert.py:342 +#: lib/cli/args_extract_convert.py:346 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -319,19 +327,18 @@ msgstr "" "을 쓸 때 스크립트가 손상될 수 있으므로 스크립트를 중단하지 마십시오. 해제하려" "면 0으로 설정" -#: lib/cli/args_extract_convert.py:353 +#: lib/cli/args_extract_convert.py:357 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "디버깅을 위해 출력 얼굴에 특징점을 그립니다." -#: lib/cli/args_extract_convert.py:359 lib/cli/args_extract_convert.py:369 -#: lib/cli/args_extract_convert.py:377 lib/cli/args_extract_convert.py:384 -#: lib/cli/args_extract_convert.py:674 lib/cli/args_extract_convert.py:686 -#: lib/cli/args_extract_convert.py:695 lib/cli/args_extract_convert.py:716 -#: lib/cli/args_extract_convert.py:722 +#: lib/cli/args_extract_convert.py:363 lib/cli/args_extract_convert.py:373 +#: lib/cli/args_extract_convert.py:381 lib/cli/args_extract_convert.py:388 +#: lib/cli/args_extract_convert.py:678 lib/cli/args_extract_convert.py:691 +#: lib/cli/args_extract_convert.py:712 lib/cli/args_extract_convert.py:718 msgid "settings" msgstr "설정" -#: lib/cli/args_extract_convert.py:361 +#: lib/cli/args_extract_convert.py:365 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " "process separately (one after the other) rather than all at the same time. " @@ -341,22 +348,22 @@ msgstr "" "는 것이 아니라 개별적으로(하나씩) 실행합니다. VRAM이 프리미엄인 경우 유용합니" "다." -#: lib/cli/args_extract_convert.py:371 +#: lib/cli/args_extract_convert.py:375 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" msgstr "이미 추출되었거나 alignments 파일에 존재하는 프레임들을 스킵합니다" -#: lib/cli/args_extract_convert.py:378 +#: lib/cli/args_extract_convert.py:382 msgid "Skip frames that already have detected faces in the alignments file" msgstr "이미 얼굴을 탐지하여 alignments 파일에 존재하는 프레임들을 스킵합니다" -#: lib/cli/args_extract_convert.py:385 +#: lib/cli/args_extract_convert.py:389 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" "탐지된 얼굴을 디스크에 저장하지 않습니다. 그저 alignments 파일을 만듭니다" -#: lib/cli/args_extract_convert.py:459 +#: lib/cli/args_extract_convert.py:463 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" @@ -364,7 +371,7 @@ msgstr "" "원본 비디오/이미지의 원래 얼굴을 최종 얼굴으로 바꿉니다.\n" "변환 플러그인은 '설정' 메뉴에서 구성할 수 있습니다" -#: lib/cli/args_extract_convert.py:481 +#: lib/cli/args_extract_convert.py:485 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " @@ -373,14 +380,14 @@ msgstr "" "이미지에서 비디오로 변환하는 경우에만 필요합니다. 소스 프레임이 추출된 원본 " "비디오(fps 및 오디오 추출용)를 입력하세요." -#: lib/cli/args_extract_convert.py:490 +#: lib/cli/args_extract_convert.py:494 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." msgstr "" "모델 폴더. 당신이 변환에 사용하고자 하는 훈련된 모델을 가진 폴더입니다." -#: lib/cli/args_extract_convert.py:501 +#: lib/cli/args_extract_convert.py:505 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -416,7 +423,7 @@ msgstr "" "공하지 않습니다.\n" "L|none: 색상 조정을 수행하지 않습니다." -#: lib/cli/args_extract_convert.py:527 +#: lib/cli/args_extract_convert.py:531 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" @@ -480,7 +487,7 @@ msgstr "" "L|predicted: 교육 중에 'Learn Mask(마스크 학습)' 옵션이 활성화된 경우에는 교" "육을 받은 모델이 만든 마스크가 사용됩니다." -#: lib/cli/args_extract_convert.py:566 +#: lib/cli/args_extract_convert.py:570 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -510,12 +517,12 @@ msgstr "" "L|pillow: [images] opencv보다 느리지만 더 많은 옵션이 있고 더 많은 형식을 지" "원합니다." -#: lib/cli/args_extract_convert.py:587 lib/cli/args_extract_convert.py:596 -#: lib/cli/args_extract_convert.py:707 +#: lib/cli/args_extract_convert.py:591 lib/cli/args_extract_convert.py:600 +#: lib/cli/args_extract_convert.py:703 msgid "Frame Processing" msgstr "프레임 처리" -#: lib/cli/args_extract_convert.py:589 +#: lib/cli/args_extract_convert.py:593 #, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " @@ -524,7 +531,7 @@ msgstr "" "최종 출력 프레임의 크기를 이 양만큼 조정합니다. 100%%는 원본의 차원에서 프레" "임을 출력합니다. 50%%는 절반 크기에서, 200%%는 두 배 크기에서" -#: lib/cli/args_extract_convert.py:598 +#: lib/cli/args_extract_convert.py:602 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -536,7 +543,7 @@ msgstr "" "으면 선택한 범위를 벗어나는 프레임이 삭제됩니다. NB: 이미지에서 변환하는 경" "우 파일 이름은 프레임 번호로 끝나야 합니다!" -#: lib/cli/args_extract_convert.py:612 +#: lib/cli/args_extract_convert.py:616 msgid "" "Scale the swapped face by this percentage. Positive values will enlarge the " "face, Negative values will shrink the face." @@ -544,7 +551,7 @@ msgstr "" "이 백분율로 교체된 면의 크기를 조정합니다. 양수 값은 얼굴을 확대하고, 음수 값" "은 얼굴을 축소합니다." -#: lib/cli/args_extract_convert.py:621 +#: lib/cli/args_extract_convert.py:625 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -558,7 +565,7 @@ msgstr "" "alignments 파일 내에 존재하거나 지정된 폴더 내에 존재하는 얼굴만 변환됩니다. " "이 항목을 공백으로 두면 alignments 파일 내에 있는 모든 얼굴이 변환됩니다." -#: lib/cli/args_extract_convert.py:636 +#: lib/cli/args_extract_convert.py:640 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -571,7 +578,7 @@ msgstr "" "분하여 추가할 수 있습니다. 주의: 얼굴 필터를 사용하면 추출 속도가 현저히 감소" "하므로 정확성을 보장할 수 없습니다." -#: lib/cli/args_extract_convert.py:649 +#: lib/cli/args_extract_convert.py:653 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -584,7 +591,7 @@ msgstr "" "가할 수 있습니다. 주의: 얼굴 필터를 사용하면 추출 속도가 현저히 감소하므로 정" "확성을 보장할 수 없습니다." -#: lib/cli/args_extract_convert.py:663 +#: lib/cli/args_extract_convert.py:667 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -595,7 +602,7 @@ msgstr "" "값. 낮은 값이 더 엄격합니다. 주의: 얼굴 필터를 사용하면 추출 속도가 현저히 감" "소하므로 정확성을 보장할 수 없습니다." -#: lib/cli/args_extract_convert.py:676 +#: lib/cli/args_extract_convert.py:680 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -611,15 +618,7 @@ msgstr "" "를 사용하려고 시도하지 않습니다. 단일 프로세스가 활성화된 경우 이 설정은 무시" "됩니다." -#: lib/cli/args_extract_convert.py:688 -msgid "" -"[LEGACY] This only needs to be selected if a legacy model is being loaded or " -"if there are multiple models in the model folder" -msgstr "" -"[LEGACY] 이것은 레거시 모델을 로드 중이거나 모델 폴더에 여러 모델이 있는 경우" -"에만 선택되어야 합니다" - -#: lib/cli/args_extract_convert.py:697 +#: lib/cli/args_extract_convert.py:693 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -633,7 +632,7 @@ msgstr "" "하고 표준 이하의 결과로 이어질 것입니다. alignments 파일이 발견되면 이 옵션" "은 무시됩니다." -#: lib/cli/args_extract_convert.py:709 +#: lib/cli/args_extract_convert.py:705 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." @@ -641,10 +640,17 @@ msgstr "" "사용시 --frame-ranges 인자를 사용하면 변경되지 않은 프레임을 버리지 않은 결과" "가 출력됩니다." -#: lib/cli/args_extract_convert.py:717 +#: lib/cli/args_extract_convert.py:713 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "모델을 바꿉니다. A -> B에서 변환하는 대신 B -> A로 변환" -#: lib/cli/args_extract_convert.py:723 +#: lib/cli/args_extract_convert.py:719 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "멀티프로세싱을 쓰지 않습니다. 느리지만 자원을 덜 소모합니다." + +#~ msgid "" +#~ "[LEGACY] This only needs to be selected if a legacy model is being loaded " +#~ "or if there are multiple models in the model folder" +#~ msgstr "" +#~ "[LEGACY] 이것은 레거시 모델을 로드 중이거나 모델 폴더에 여러 모델이 있는 " +#~ "경우에만 선택되어야 합니다" diff --git a/locales/kr/LC_MESSAGES/tools.alignments.cli.mo b/locales/kr/LC_MESSAGES/tools.alignments.cli.mo index ec090bd06a601115a38d42b0eebb487d911f69a0..21920ec6dc38692a280a7a7172cb86d9c27a0b74 100644 GIT binary patch delta 1219 zcmYjPQD|FL82+oXYGq7um37YXKP?X1kd%U0@S#kF4V~aX!52AAZqw^-?+y2!E}ab1 ztr1ESZKMrtHJKSy+Kx=KrK@2?+}mEn7oQYF1o0u~TwnGezWJRbEgo|7oqPW8|Nrm* z&fR_gCs9mhe|v&x@G+uOeMAor6QvIk9X~?!77^v2CHfio3y>Zmx^7 z06q@<3pfD$8+a0R{sq2*_hZM1QowhB8Q?wO$H37cq6y&bA%yQ@^AHE7;m&cQ4^ZGA z;20{tcLERI3#W*FMaMrQL^gOo3=<9E{PYNT5OyX?^eWEhVnj~@_kjJt`#pRRTRGQv zGL92^%KTwr)Xp;cpT=X-Oo|!REZ9N=jg7vK4!w}}d>I+%*XPWJ<+Q~K&y|)d1NNp? zCLTSSl^zTJFz{TSvK=eRXN1d>7S9G&lBYbMt)wk&H^suSQ|=5h*>-V0BczQS+huD` zTD~hBmY(MXEZn5&-Gnongj|R*6Iz{NjeHC=%z@Ws zg9%9~%e8zV5zQEpGK++R>Ddmrs~Pc0k+_JG@Mm9hlqb+NDWovOgEkpkPInr6z76^O zVr-aWr&)CUmR_12Yf8fJuK&WlB!cL`n^z)m&TkC%UpaDkY$QI)@zL@4%P+;^jq8JB z{n_5l>1s(;YaA8|y3*2>bynAQ^%pz3lH+q1-ag+8s2gPyP>o#^&fn7cvR>bevfgN_ zy?Iv4x%SvIlEsFB~)m4X@gUF!mF$u!h^t9q}jYORBN z3Res2t6KZJrmodSogHCA_Sgt>YrXMdb_v%Ww6T9L!$T3Fgv#dWDf*=m#Fj@mgO>4l2U-B!gNm0yGfQC2s%)k@XqMyZ}4eQ#CW x$w6+%l#Q0Y`8{JqmEShTUSBk#^_N9mE%v?uBdvQcY@A;Bq*uN1=e#}VdCuGE^YOTMTa80D3cd&J1YAYnG6`K^l7@F_GWyMc zw+uBm%z}>5kV>Z|=jDMc>7K4^YTjRgbNL{zWhw^)Ih6Y{n%DM=2JQTB!Ebq@3pxcj zoQ0<%9BKZw0UxRuY{G@kZ*4mylLV~;zqr}fVY%*j;)G3ozl%w6D>s;\n" "Language-Team: LANGUAGE \n" @@ -19,7 +19,7 @@ msgstr "" #: lib/cli/args_extract_convert.py:46 lib/cli/args_extract_convert.py:56 #: lib/cli/args_extract_convert.py:64 lib/cli/args_extract_convert.py:122 -#: lib/cli/args_extract_convert.py:479 lib/cli/args_extract_convert.py:488 +#: lib/cli/args_extract_convert.py:483 lib/cli/args_extract_convert.py:492 msgid "Data" msgstr "" @@ -53,12 +53,12 @@ msgid "" "will be output to separate sub-folders in the output_dir." msgstr "" -#: lib/cli/args_extract_convert.py:133 lib/cli/args_extract_convert.py:150 -#: lib/cli/args_extract_convert.py:163 lib/cli/args_extract_convert.py:202 -#: lib/cli/args_extract_convert.py:220 lib/cli/args_extract_convert.py:233 -#: lib/cli/args_extract_convert.py:243 lib/cli/args_extract_convert.py:253 -#: lib/cli/args_extract_convert.py:499 lib/cli/args_extract_convert.py:525 -#: lib/cli/args_extract_convert.py:564 +#: lib/cli/args_extract_convert.py:133 lib/cli/args_extract_convert.py:152 +#: lib/cli/args_extract_convert.py:167 lib/cli/args_extract_convert.py:206 +#: lib/cli/args_extract_convert.py:224 lib/cli/args_extract_convert.py:237 +#: lib/cli/args_extract_convert.py:247 lib/cli/args_extract_convert.py:257 +#: lib/cli/args_extract_convert.py:503 lib/cli/args_extract_convert.py:529 +#: lib/cli/args_extract_convert.py:568 msgid "Plugins" msgstr "" @@ -72,18 +72,22 @@ msgid "" "than other GPU detectors but can often return more false positives.\n" "L|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and " "fewer false positives than other GPU detectors, but is a lot more resource " -"intensive." +"intensive.\n" +"L|external: Import a face detection bounding box from a json file. " +"(configurable in Detect settings)" msgstr "" -#: lib/cli/args_extract_convert.py:152 +#: lib/cli/args_extract_convert.py:154 msgid "" "R|Aligner to use.\n" "L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " "but less accurate. Only use this if not using a GPU and time is important.\n" -"L|fan: Best aligner. Fast on GPU, slow on CPU." +"L|fan: Best aligner. Fast on GPU, slow on CPU.\n" +"L|external: Import 68 point 2D landmarks or an aligned bounding box from a " +"json file. (configurable in Align settings)" msgstr "" -#: lib/cli/args_extract_convert.py:165 +#: lib/cli/args_extract_convert.py:169 msgid "" "R|Additional Masker(s) to use. The masks generated here will all take up GPU " "RAM. You can select none, one or multiple masks, but the extraction may take " @@ -118,7 +122,7 @@ msgid "" "(eg: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" msgstr "" -#: lib/cli/args_extract_convert.py:204 +#: lib/cli/args_extract_convert.py:208 msgid "" "R|Performing normalization can help the aligner better align faces with " "difficult lighting conditions at an extraction speed cost. Different methods " @@ -131,7 +135,7 @@ msgid "" "L|mean: Normalize the face colors to the mean." msgstr "" -#: lib/cli/args_extract_convert.py:222 +#: lib/cli/args_extract_convert.py:226 msgid "" "The number of times to re-feed the detected face into the aligner. Each time " "the face is re-fed into the aligner the bounding box is adjusted by a small " @@ -141,14 +145,14 @@ msgid "" "occur but the longer extraction will take." msgstr "" -#: lib/cli/args_extract_convert.py:235 +#: lib/cli/args_extract_convert.py:239 msgid "" "Re-feed the initially found aligned face through the aligner. Can help " "produce better alignments for faces that are rotated beyond 45 degrees in " "the frame or are at extreme angles. Slows down extraction." msgstr "" -#: lib/cli/args_extract_convert.py:245 +#: lib/cli/args_extract_convert.py:249 msgid "" "If a face isn't found, rotate the images to try to find a face. Can find " "more faces at the cost of extraction speed. Pass in a single number to use " @@ -156,27 +160,27 @@ msgid "" "exactly what angles to check." msgstr "" -#: lib/cli/args_extract_convert.py:255 +#: lib/cli/args_extract_convert.py:259 msgid "" "Obtain and store face identity encodings from VGGFace2. Slows down extract a " "little, but will save time if using 'sort by face'" msgstr "" -#: lib/cli/args_extract_convert.py:265 lib/cli/args_extract_convert.py:276 -#: lib/cli/args_extract_convert.py:289 lib/cli/args_extract_convert.py:303 -#: lib/cli/args_extract_convert.py:610 lib/cli/args_extract_convert.py:619 -#: lib/cli/args_extract_convert.py:634 lib/cli/args_extract_convert.py:647 -#: lib/cli/args_extract_convert.py:661 +#: lib/cli/args_extract_convert.py:269 lib/cli/args_extract_convert.py:280 +#: lib/cli/args_extract_convert.py:293 lib/cli/args_extract_convert.py:307 +#: lib/cli/args_extract_convert.py:614 lib/cli/args_extract_convert.py:623 +#: lib/cli/args_extract_convert.py:638 lib/cli/args_extract_convert.py:651 +#: lib/cli/args_extract_convert.py:665 msgid "Face Processing" msgstr "" -#: lib/cli/args_extract_convert.py:267 +#: lib/cli/args_extract_convert.py:271 msgid "" "Filters out faces detected below this size. Length, in pixels across the " "diagonal of the bounding box. Set to 0 for off" msgstr "" -#: lib/cli/args_extract_convert.py:278 +#: lib/cli/args_extract_convert.py:282 msgid "" "Optionally filter out people who you do not wish to extract by passing in " "images of those people. Should be a small variety of images at different " @@ -184,7 +188,7 @@ msgid "" "or multiple image files, space separated, can be selected." msgstr "" -#: lib/cli/args_extract_convert.py:291 +#: lib/cli/args_extract_convert.py:295 msgid "" "Optionally select people you wish to extract by passing in images of that " "person. Should be a small variety of images at different angles and in " @@ -192,32 +196,32 @@ msgid "" "image files, space separated, can be selected." msgstr "" -#: lib/cli/args_extract_convert.py:305 +#: lib/cli/args_extract_convert.py:309 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Higher values are stricter." msgstr "" -#: lib/cli/args_extract_convert.py:314 lib/cli/args_extract_convert.py:327 -#: lib/cli/args_extract_convert.py:340 lib/cli/args_extract_convert.py:352 +#: lib/cli/args_extract_convert.py:318 lib/cli/args_extract_convert.py:331 +#: lib/cli/args_extract_convert.py:344 lib/cli/args_extract_convert.py:356 msgid "output" msgstr "" -#: lib/cli/args_extract_convert.py:316 +#: lib/cli/args_extract_convert.py:320 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" "res models." msgstr "" -#: lib/cli/args_extract_convert.py:329 +#: lib/cli/args_extract_convert.py:333 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " "of 10 will extract faces from every 10th frame." msgstr "" -#: lib/cli/args_extract_convert.py:342 +#: lib/cli/args_extract_convert.py:346 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -227,59 +231,58 @@ msgid "" "turn off" msgstr "" -#: lib/cli/args_extract_convert.py:353 +#: lib/cli/args_extract_convert.py:357 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "" -#: lib/cli/args_extract_convert.py:359 lib/cli/args_extract_convert.py:369 -#: lib/cli/args_extract_convert.py:377 lib/cli/args_extract_convert.py:384 -#: lib/cli/args_extract_convert.py:674 lib/cli/args_extract_convert.py:686 -#: lib/cli/args_extract_convert.py:695 lib/cli/args_extract_convert.py:716 -#: lib/cli/args_extract_convert.py:722 +#: lib/cli/args_extract_convert.py:363 lib/cli/args_extract_convert.py:373 +#: lib/cli/args_extract_convert.py:381 lib/cli/args_extract_convert.py:388 +#: lib/cli/args_extract_convert.py:678 lib/cli/args_extract_convert.py:691 +#: lib/cli/args_extract_convert.py:712 lib/cli/args_extract_convert.py:718 msgid "settings" msgstr "" -#: lib/cli/args_extract_convert.py:361 +#: lib/cli/args_extract_convert.py:365 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " "process separately (one after the other) rather than all at the same time. " "Useful if VRAM is at a premium." msgstr "" -#: lib/cli/args_extract_convert.py:371 +#: lib/cli/args_extract_convert.py:375 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" msgstr "" -#: lib/cli/args_extract_convert.py:378 +#: lib/cli/args_extract_convert.py:382 msgid "Skip frames that already have detected faces in the alignments file" msgstr "" -#: lib/cli/args_extract_convert.py:385 +#: lib/cli/args_extract_convert.py:389 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" -#: lib/cli/args_extract_convert.py:459 +#: lib/cli/args_extract_convert.py:463 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" msgstr "" -#: lib/cli/args_extract_convert.py:481 +#: lib/cli/args_extract_convert.py:485 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " "audio)." msgstr "" -#: lib/cli/args_extract_convert.py:490 +#: lib/cli/args_extract_convert.py:494 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." msgstr "" -#: lib/cli/args_extract_convert.py:501 +#: lib/cli/args_extract_convert.py:505 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -300,7 +303,7 @@ msgid "" "L|none: Don't perform color adjustment." msgstr "" -#: lib/cli/args_extract_convert.py:527 +#: lib/cli/args_extract_convert.py:531 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" @@ -337,7 +340,7 @@ msgid "" "will use the mask that was created by the trained model." msgstr "" -#: lib/cli/args_extract_convert.py:566 +#: lib/cli/args_extract_convert.py:570 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -356,19 +359,19 @@ msgid "" "more formats." msgstr "" -#: lib/cli/args_extract_convert.py:587 lib/cli/args_extract_convert.py:596 -#: lib/cli/args_extract_convert.py:707 +#: lib/cli/args_extract_convert.py:591 lib/cli/args_extract_convert.py:600 +#: lib/cli/args_extract_convert.py:703 msgid "Frame Processing" msgstr "" -#: lib/cli/args_extract_convert.py:589 +#: lib/cli/args_extract_convert.py:593 #, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " "at source dimensions. 50%% at half size 200%% at double size" msgstr "" -#: lib/cli/args_extract_convert.py:598 +#: lib/cli/args_extract_convert.py:602 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -376,13 +379,13 @@ msgid "" "converting from images, then the filenames must end with the frame-number!" msgstr "" -#: lib/cli/args_extract_convert.py:612 +#: lib/cli/args_extract_convert.py:616 msgid "" "Scale the swapped face by this percentage. Positive values will enlarge the " "face, Negative values will shrink the face." msgstr "" -#: lib/cli/args_extract_convert.py:621 +#: lib/cli/args_extract_convert.py:625 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -392,7 +395,7 @@ msgid "" "alignments file." msgstr "" -#: lib/cli/args_extract_convert.py:636 +#: lib/cli/args_extract_convert.py:640 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -401,7 +404,7 @@ msgid "" "guaranteed." msgstr "" -#: lib/cli/args_extract_convert.py:649 +#: lib/cli/args_extract_convert.py:653 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -410,7 +413,7 @@ msgid "" "guaranteed." msgstr "" -#: lib/cli/args_extract_convert.py:663 +#: lib/cli/args_extract_convert.py:667 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -418,7 +421,7 @@ msgid "" "guaranteed." msgstr "" -#: lib/cli/args_extract_convert.py:676 +#: lib/cli/args_extract_convert.py:680 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -428,13 +431,7 @@ msgid "" "your system. If singleprocess is enabled this setting will be ignored." msgstr "" -#: lib/cli/args_extract_convert.py:688 -msgid "" -"[LEGACY] This only needs to be selected if a legacy model is being loaded or " -"if there are multiple models in the model folder" -msgstr "" - -#: lib/cli/args_extract_convert.py:697 +#: lib/cli/args_extract_convert.py:693 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -443,16 +440,16 @@ msgid "" "alignments file is found, this option will be ignored." msgstr "" -#: lib/cli/args_extract_convert.py:709 +#: lib/cli/args_extract_convert.py:705 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." msgstr "" -#: lib/cli/args_extract_convert.py:717 +#: lib/cli/args_extract_convert.py:713 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" -#: lib/cli/args_extract_convert.py:723 +#: lib/cli/args_extract_convert.py:719 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "" diff --git a/locales/ru/LC_MESSAGES/lib.cli.args_extract_convert.mo b/locales/ru/LC_MESSAGES/lib.cli.args_extract_convert.mo index 4483776f6fe3e12dd20f74b171b1a75ddcce4638..51d7f9676fcfebc7feab5b9dedf7ce6c1c5e45cd 100644 GIT binary patch delta 1733 zcmb7@Ur^Ll6vw|18Jc7&2xP$pvp@q~R8)pZNi0FM3B~Eps4VbX+Js$P7R4eL)X}WQ zY>-K#nu)R}ofbC?#9*eJX&RmU{T`bMpQ^VW(&^7kllJ-TG8K(Ibm9Cy_nvd^x#ymH z_V|_L<6kF7uFXm56QcyVA32jGt%K!v@L_C7kvh;-LH;#C!CtBHRmKg<+UHLz<8x+RNl#6m@q?#RTxdo2>h0N{3mWNRx(G&nL=r z(18(H#HN0D7B<4F*-|$CXW=UN^}vC0{y%y>5Z@6ZVNk+>q*AeMCxwCzfKzZ(v#ChKrUbDmVZiNB;xd z42xDI66k_GtjFQ`2mvcf*$jVM2{H6j&sO+onM5^p22xGM;4b_wx_S;S;2kKEmaIus zq#TyIcIZWaW3BWB7pbd|wxiFkls-qlcRh1hMs|^z7`8H*1-(y5-@x4)5)K=yr8NYw zU^+ZqBYg`ma2+?`TTe+N1p2u)vH#;OB#nN2E6;@fW}TFezr9U*3Ep^`xG=~&J`ay> zr~Yj?Uf(I*0~_lpIRQd22gRBO=_?#};N+p<3fiRu*q`l`@~}_rlRBxyE$aUsPt^Gu z?~7k%uQUU{jVGnwiM#ETgh{n;(^K#od=pyzk%Zz`I_Vb_``@8CX!DMDrL_cp`F-gb z0*!r0Ww2*`L|}Lw(qH-mV%CGm0|>pV0^}iNHbQU4M+V>d2sz4wxW6>oM>(7y|9RX8 zmmmhoLlz^`5O=t5?~aEPh$oQEoL`dik-3w47j8eeiRB_$h&$2~5QP!F{?}N@Y!gi{ z8d@}Qe3bAlnw-0!7*oaqR=kKPPJ9&dEk@kuON&`41@j{_YPzh>uoVh;TgpsTYg;fB zHl?dfThJc}o03Y?;te#ldPBR~O)zA<0po4)HwUaH(-7Xz*M;VOuH2hv#GsMwOy4~*lLXR<(4K}w@boneBMUO zG`U`3KY{=6iShX1zmJSv&Rdw(8z|eFw!FwwVmzL*6{{C7^~A20WvtvWB?EidzG#oz zV@|hoz%7P7Xh-c){>JQcZcXe@u=O|xSdTm1aql7Lh#f^UWRE(p+n-I^hYiM2d$_l! zK7D=EdBdBopXWkuswBrX boWuW4EA^jw&AaW+!q~b!r;2;8pU(Lco}$38 delta 1544 zcmYk6eN5G56vw|Hk%?p?FOnt?7!ke@FPaF1kQ!N7E9`|a!VCO>TbFwUE~)F~0*aXaJ{;6D_-vye_?)iPrInVi>-}9X3 zJa_-s@%ty@LswH04vJBNEJnuSq#9T|iwC14LF&clh5U5w7O4VWhbLinqO=x%1Gf^F ze5>?1wtu$N21{;}=E0+IJNyU+Va^=sM=7MkD0krKO_B;o@EW{<9hr-cJ(i4)T~3w; zI0-{=GcUajFT)n-oiAk)e;Jm-UsI&n(4Zi8bmF{L0OA3}rK(=QE^l znHXOpJrFOwnI%nNPh?BGnIF#K<0Q&n#tx~@f)(r*es>@9#Qjn{wL?FpT<_ZOA-HUn z^cd`d?gB2semHBjbSgx`L6i|#u?8}6tyJoS1Jv~u_y?rA@|UqA*b84E{#BUB0)8)- zD3&HGrz&#;-tW$@lRWq%Rnli{D5F|x#Qy`U`xO7$jl^f7{DU%=iFB4b4=$~dzK10b zQ&Q%WACcCPAP=U(_FCx*e3@;z3G1Jb&JcI{$*K3d>ZD5i{ad6c{!34B-o(H4wDb~; zJWF0!)Ij|^P#$fRwh=hiB;5{kw^DWztb$8$#5GfLY(vm6l zP>J6LIZlqM`3)9NgT@_@<`B2~kn}fss}4(hV8s!73x0Wo^Cw1+4^KIMf`4Im9HmM4 zP!ke^Kgl}jO;*OOFy(Df*Y#5$}$lXXLvK(=%MBnKiWGON;3Q+QqduJRsF&DiE;lk)n zBm)V#4V8vaHRT|jMrcwTQqiT+%-)XiAdJ8oetgq5_fP}VuM?)R9CC+ z4F)iz8EAEUhVW*`>u)!{fTxv+K(`wd>~Ojr<3T!meL?Rw zABr2^W4wNMa{A@AfUni*R$%DkvbyAwmBpo|sI;uOczI!AIJrD+O*pToDIr|HW4Iut z!5*_C_JDm49=8YWJB>q|HZ0Eh#767|donu6bIgw0^Y((VqtQLM`s|75Ky>fW)waUW zX?wzkqkYj`_7qwKe}8nJiS7aeg!PeSH2ON)KI5h!Rm7f)?lojQXU7e1^iL_H#$F`D nMSIppZVDy*j2nC&WhT32ZM=4tbVHk3l9Hygx}igpA7=drVbfDM diff --git a/locales/ru/LC_MESSAGES/lib.cli.args_extract_convert.po b/locales/ru/LC_MESSAGES/lib.cli.args_extract_convert.po index 6924835..e95bf84 100755 --- a/locales/ru/LC_MESSAGES/lib.cli.args_extract_convert.po +++ b/locales/ru/LC_MESSAGES/lib.cli.args_extract_convert.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-28 18:11+0000\n" -"PO-Revision-Date: 2024-03-28 18:22+0000\n" +"POT-Creation-Date: 2024-04-12 11:56+0100\n" +"PO-Revision-Date: 2024-04-12 11:59+0100\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru\n" @@ -21,7 +21,7 @@ msgstr "" #: lib/cli/args_extract_convert.py:46 lib/cli/args_extract_convert.py:56 #: lib/cli/args_extract_convert.py:64 lib/cli/args_extract_convert.py:122 -#: lib/cli/args_extract_convert.py:479 lib/cli/args_extract_convert.py:488 +#: lib/cli/args_extract_convert.py:483 lib/cli/args_extract_convert.py:492 msgid "Data" msgstr "Данные" @@ -65,12 +65,12 @@ msgstr "" "несколько видео и/или папок с изображениями, из которых вы хотите извлечь " "изображение. Лица будут выведены в отдельные вложенные папки в output_dir." -#: lib/cli/args_extract_convert.py:133 lib/cli/args_extract_convert.py:150 -#: lib/cli/args_extract_convert.py:163 lib/cli/args_extract_convert.py:202 -#: lib/cli/args_extract_convert.py:220 lib/cli/args_extract_convert.py:233 -#: lib/cli/args_extract_convert.py:243 lib/cli/args_extract_convert.py:253 -#: lib/cli/args_extract_convert.py:499 lib/cli/args_extract_convert.py:525 -#: lib/cli/args_extract_convert.py:564 +#: lib/cli/args_extract_convert.py:133 lib/cli/args_extract_convert.py:152 +#: lib/cli/args_extract_convert.py:167 lib/cli/args_extract_convert.py:206 +#: lib/cli/args_extract_convert.py:224 lib/cli/args_extract_convert.py:237 +#: lib/cli/args_extract_convert.py:247 lib/cli/args_extract_convert.py:257 +#: lib/cli/args_extract_convert.py:503 lib/cli/args_extract_convert.py:529 +#: lib/cli/args_extract_convert.py:568 msgid "Plugins" msgstr "Плагины" @@ -84,7 +84,9 @@ msgid "" "than other GPU detectors but can often return more false positives.\n" "L|s3fd: Best detector. Slow on CPU, faster on GPU. Can detect more faces and " "fewer false positives than other GPU detectors, but is a lot more resource " -"intensive." +"intensive.\n" +"L|external: Import a face detection bounding box from a json file. " +"(configurable in Detect settings)" msgstr "" "R|Детектор для использования. Некоторые из них имеют настраиваемые параметры " "в '/config/extract.ini' или 'Settings > Configure Extract 'Plugins':\n" @@ -96,22 +98,29 @@ msgstr "" "ложных срабатываний.\n" "L|s3fd: Лучший детектор. Медленный на CPU, более быстрый на GPU. Может " "обнаружить больше лиц и меньше ложных срабатываний, чем другие детекторы на " -"GPU, но требует гораздо больше ресурсов." +"GPU, но требует гораздо больше ресурсов.\n" +"L|external: импортируйте ограничивающую коробку обнаружения лица из файла " +"JSON. (настраивается в настройках обнаружения)" -#: lib/cli/args_extract_convert.py:152 +#: lib/cli/args_extract_convert.py:154 msgid "" "R|Aligner to use.\n" "L|cv2-dnn: A CPU only landmark detector. Faster, less resource intensive, " "but less accurate. Only use this if not using a GPU and time is important.\n" -"L|fan: Best aligner. Fast on GPU, slow on CPU." +"L|fan: Best aligner. Fast on GPU, slow on CPU.\n" +"L|external: Import 68 point 2D landmarks or an aligned bounding box from a " +"json file. (configurable in Align settings)" msgstr "" "R|Выравниватель для использования.\n" "L|cv2-dnn: Детектор ориентиров только для процессора. Быстрее, менее " "ресурсоемкий, но менее точный. Используйте его, только если не используется " "GPU и важно время.\n" -"L|fan: Лучший выравниватель. Быстрый на GPU, медленный на CPU." +"L|fan: Лучший выравниватель. Быстрый на GPU, медленный на CPU.\n" +"L|external: импорт 68 баллов 2D достопримечательности или выровненная " +"ограничивающая коробка из файла JSON. (настраивается в настройках " +"выравнивания)" -#: lib/cli/args_extract_convert.py:165 +#: lib/cli/args_extract_convert.py:169 msgid "" "R|Additional Masker(s) to use. The masks generated here will all take up GPU " "RAM. You can select none, one or multiple masks, but the extraction may take " @@ -178,7 +187,7 @@ msgstr "" "и маска расширяется вверх на лоб.\n" "(например: `-M unet-dfl vgg-clear`, `--masker vgg-obstructed`)" -#: lib/cli/args_extract_convert.py:204 +#: lib/cli/args_extract_convert.py:208 msgid "" "R|Performing normalization can help the aligner better align faces with " "difficult lighting conditions at an extraction speed cost. Different methods " @@ -200,7 +209,7 @@ msgstr "" "L|hist: Уравнять гистограммы в каналах RGB.\n" "L|mean: Нормализовать цвета лица к среднему значению." -#: lib/cli/args_extract_convert.py:222 +#: lib/cli/args_extract_convert.py:226 msgid "" "The number of times to re-feed the detected face into the aligner. Each time " "the face is re-fed into the aligner the bounding box is adjusted by a small " @@ -217,7 +226,7 @@ msgstr "" "в выравниватель, тем меньше микро-дрожание, но тем больше времени займет " "извлечение." -#: lib/cli/args_extract_convert.py:235 +#: lib/cli/args_extract_convert.py:239 msgid "" "Re-feed the initially found aligned face through the aligner. Can help " "produce better alignments for faces that are rotated beyond 45 degrees in " @@ -228,7 +237,7 @@ msgstr "" "в кадре более чем на 45 градусов или расположенных под экстремальными " "углами. Замедляет извлечение." -#: lib/cli/args_extract_convert.py:245 +#: lib/cli/args_extract_convert.py:249 msgid "" "If a face isn't found, rotate the images to try to find a face. Can find " "more faces at the cost of extraction speed. Pass in a single number to use " @@ -240,7 +249,7 @@ msgstr "" "число, чтобы использовать приращения этого размера до 360, или передайте " "список чисел, чтобы перечислить, какие именно углы нужно проверить." -#: lib/cli/args_extract_convert.py:255 +#: lib/cli/args_extract_convert.py:259 msgid "" "Obtain and store face identity encodings from VGGFace2. Slows down extract a " "little, but will save time if using 'sort by face'" @@ -249,15 +258,15 @@ msgstr "" "замедляет извлечение, но экономит время при использовании \"сортировки по " "лицам\"." -#: lib/cli/args_extract_convert.py:265 lib/cli/args_extract_convert.py:276 -#: lib/cli/args_extract_convert.py:289 lib/cli/args_extract_convert.py:303 -#: lib/cli/args_extract_convert.py:610 lib/cli/args_extract_convert.py:619 -#: lib/cli/args_extract_convert.py:634 lib/cli/args_extract_convert.py:647 -#: lib/cli/args_extract_convert.py:661 +#: lib/cli/args_extract_convert.py:269 lib/cli/args_extract_convert.py:280 +#: lib/cli/args_extract_convert.py:293 lib/cli/args_extract_convert.py:307 +#: lib/cli/args_extract_convert.py:614 lib/cli/args_extract_convert.py:623 +#: lib/cli/args_extract_convert.py:638 lib/cli/args_extract_convert.py:651 +#: lib/cli/args_extract_convert.py:665 msgid "Face Processing" msgstr "Обработка лиц" -#: lib/cli/args_extract_convert.py:267 +#: lib/cli/args_extract_convert.py:271 msgid "" "Filters out faces detected below this size. Length, in pixels across the " "diagonal of the bounding box. Set to 0 for off" @@ -265,7 +274,7 @@ msgstr "" "Отфильтровывает лица, обнаруженные ниже этого размера. Длина в пикселях по " "диагонали ограничивающего поля. Установите значение 0, чтобы выключить" -#: lib/cli/args_extract_convert.py:278 +#: lib/cli/args_extract_convert.py:282 msgid "" "Optionally filter out people who you do not wish to extract by passing in " "images of those people. Should be a small variety of images at different " @@ -278,7 +287,7 @@ msgstr "" "необходимые изображения, или несколько файлов изображений, разделенных " "пробелами." -#: lib/cli/args_extract_convert.py:291 +#: lib/cli/args_extract_convert.py:295 msgid "" "Optionally select people you wish to extract by passing in images of that " "person. Should be a small variety of images at different angles and in " @@ -290,7 +299,7 @@ msgstr "" "углами и в разных условиях. Можно выбрать папку, содержащую необходимые " "изображения, или несколько файлов изображений, разделенных пробелами." -#: lib/cli/args_extract_convert.py:305 +#: lib/cli/args_extract_convert.py:309 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Higher values are stricter." @@ -299,12 +308,12 @@ msgstr "" "положительного распознавания лица. Более высокие значения являются более " "строгими." -#: lib/cli/args_extract_convert.py:314 lib/cli/args_extract_convert.py:327 -#: lib/cli/args_extract_convert.py:340 lib/cli/args_extract_convert.py:352 +#: lib/cli/args_extract_convert.py:318 lib/cli/args_extract_convert.py:331 +#: lib/cli/args_extract_convert.py:344 lib/cli/args_extract_convert.py:356 msgid "output" msgstr "вывод" -#: lib/cli/args_extract_convert.py:316 +#: lib/cli/args_extract_convert.py:320 msgid "" "The output size of extracted faces. Make sure that the model you intend to " "train supports your required size. This will only need to be changed for hi-" @@ -314,7 +323,7 @@ msgstr "" "собираетесь тренировать, поддерживает требуемый размер. Это необходимо " "изменить только для моделей высокого разрешения." -#: lib/cli/args_extract_convert.py:329 +#: lib/cli/args_extract_convert.py:333 msgid "" "Extract every 'nth' frame. This option will skip frames when extracting " "faces. For example a value of 1 will extract faces from every frame, a value " @@ -324,7 +333,7 @@ msgstr "" "лиц. Например, значение 1 будет извлекать лица из каждого кадра, значение 10 " "будет извлекать лица из каждого 10-го кадра." -#: lib/cli/args_extract_convert.py:342 +#: lib/cli/args_extract_convert.py:346 msgid "" "Automatically save the alignments file after a set amount of frames. By " "default the alignments file is only saved at the end of the extraction " @@ -340,19 +349,18 @@ msgstr "" "ПРЕДУПРЕЖДЕНИЕ: Не прерывайте работу скрипта при записи файла, так как он " "может быть поврежден. Установите значение 0, чтобы отключить" -#: lib/cli/args_extract_convert.py:353 +#: lib/cli/args_extract_convert.py:357 msgid "Draw landmarks on the ouput faces for debugging purposes." msgstr "Нарисуйте ориентиры на выходящих гранях для отладки." -#: lib/cli/args_extract_convert.py:359 lib/cli/args_extract_convert.py:369 -#: lib/cli/args_extract_convert.py:377 lib/cli/args_extract_convert.py:384 -#: lib/cli/args_extract_convert.py:674 lib/cli/args_extract_convert.py:686 -#: lib/cli/args_extract_convert.py:695 lib/cli/args_extract_convert.py:716 -#: lib/cli/args_extract_convert.py:722 +#: lib/cli/args_extract_convert.py:363 lib/cli/args_extract_convert.py:373 +#: lib/cli/args_extract_convert.py:381 lib/cli/args_extract_convert.py:388 +#: lib/cli/args_extract_convert.py:678 lib/cli/args_extract_convert.py:691 +#: lib/cli/args_extract_convert.py:712 lib/cli/args_extract_convert.py:718 msgid "settings" msgstr "настройки" -#: lib/cli/args_extract_convert.py:361 +#: lib/cli/args_extract_convert.py:365 msgid "" "Don't run extraction in parallel. Will run each part of the extraction " "process separately (one after the other) rather than all at the same time. " @@ -362,7 +370,7 @@ msgstr "" "выполняться отдельно (одна за другой), а не одновременно. Полезно, если " "память VRAM ограничена." -#: lib/cli/args_extract_convert.py:371 +#: lib/cli/args_extract_convert.py:375 msgid "" "Skips frames that have already been extracted and exist in the alignments " "file" @@ -370,17 +378,17 @@ msgstr "" "Пропускает кадры, которые уже были извлечены и существуют в файле " "выравнивания" -#: lib/cli/args_extract_convert.py:378 +#: lib/cli/args_extract_convert.py:382 msgid "Skip frames that already have detected faces in the alignments file" msgstr "" "Пропустить кадры, в которых уже есть обнаруженные лица в файле выравнивания" -#: lib/cli/args_extract_convert.py:385 +#: lib/cli/args_extract_convert.py:389 msgid "Skip saving the detected faces to disk. Just create an alignments file" msgstr "" "Не сохранять обнаруженные лица на диск. Просто создать файл выравнивания" -#: lib/cli/args_extract_convert.py:459 +#: lib/cli/args_extract_convert.py:463 msgid "" "Swap the original faces in a source video/images to your final faces.\n" "Conversion plugins can be configured in the 'Settings' Menu" @@ -388,7 +396,7 @@ msgstr "" "Поменять исходные лица в исходном видео/изображении на ваши конечные лица.\n" "Плагины конвертирования можно настроить в меню \"Настройки\"" -#: lib/cli/args_extract_convert.py:481 +#: lib/cli/args_extract_convert.py:485 msgid "" "Only required if converting from images to video. Provide The original video " "that the source frames were extracted from (for extracting the fps and " @@ -398,7 +406,7 @@ msgstr "" "исходное видео, из которого были извлечены исходные кадры (для извлечения " "кадров в секунду и звука)." -#: lib/cli/args_extract_convert.py:490 +#: lib/cli/args_extract_convert.py:494 msgid "" "Model directory. The directory containing the trained model you wish to use " "for conversion." @@ -406,7 +414,7 @@ msgstr "" "Папка модели. Папка, содержащая обученную модель, которую вы хотите " "использовать для преобразования." -#: lib/cli/args_extract_convert.py:501 +#: lib/cli/args_extract_convert.py:505 msgid "" "R|Performs color adjustment to the swapped face. Some of these options have " "configurable settings in '/config/convert.ini' or 'Settings > Configure " @@ -446,7 +454,7 @@ msgstr "" "Обычно дает не очень удовлетворительные результаты.\n" "L|none: Не выполнять коррекцию цвета." -#: lib/cli/args_extract_convert.py:527 +#: lib/cli/args_extract_convert.py:531 msgid "" "R|Masker to use. NB: The mask you require must exist within the alignments " "file. You can add additional masks with the Mask Tool.\n" @@ -517,7 +525,7 @@ msgstr "" "L|predicted: Если во время обучения была включена опция 'Изучить Маску', то " "будет использоваться маска, созданная обученной моделью." -#: lib/cli/args_extract_convert.py:566 +#: lib/cli/args_extract_convert.py:570 msgid "" "R|The plugin to use to output the converted images. The writers are " "configurable in '/config/convert.ini' or 'Settings > Configure Convert " @@ -550,12 +558,12 @@ msgstr "" "L|pillow: [изображения] Медленнее, чем opencv, но имеет больше опций и " "поддерживает больше форматов." -#: lib/cli/args_extract_convert.py:587 lib/cli/args_extract_convert.py:596 -#: lib/cli/args_extract_convert.py:707 +#: lib/cli/args_extract_convert.py:591 lib/cli/args_extract_convert.py:600 +#: lib/cli/args_extract_convert.py:703 msgid "Frame Processing" msgstr "Обработка лиц" -#: lib/cli/args_extract_convert.py:589 +#: lib/cli/args_extract_convert.py:593 #, python-format msgid "" "Scale the final output frames by this amount. 100%% will output the frames " @@ -565,7 +573,7 @@ msgstr "" "кадры в исходном размере. 50%% при половинном размере 200%% при двойном " "размере" -#: lib/cli/args_extract_convert.py:598 +#: lib/cli/args_extract_convert.py:602 msgid "" "Frame ranges to apply transfer to e.g. For frames 10 to 50 and 90 to 100 use " "--frame-ranges 10-50 90-100. Frames falling outside of the selected range " @@ -578,7 +586,7 @@ msgstr "" "keep-unchanged). Примечание: Если вы конвертируете из изображений, то имена " "файлов должны заканчиваться номером кадра!" -#: lib/cli/args_extract_convert.py:612 +#: lib/cli/args_extract_convert.py:616 msgid "" "Scale the swapped face by this percentage. Positive values will enlarge the " "face, Negative values will shrink the face." @@ -586,7 +594,7 @@ msgstr "" "Увеличить масштаб нового лица на этот процент. Положительные значения " "увеличат лицо, в то время как отрицательные значения уменьшат его." -#: lib/cli/args_extract_convert.py:621 +#: lib/cli/args_extract_convert.py:625 msgid "" "If you have not cleansed your alignments file, then you can filter out faces " "by defining a folder here that contains the faces extracted from your input " @@ -602,7 +610,7 @@ msgstr "" "Если оставить этот параметр пустым, будут преобразованы все лица, " "существующие в файле выравнивания." -#: lib/cli/args_extract_convert.py:636 +#: lib/cli/args_extract_convert.py:640 msgid "" "Optionally filter out people who you do not wish to process by passing in an " "image of that person. Should be a front portrait with a single person in the " @@ -616,7 +624,7 @@ msgstr "" "разделенных пробелами. Примечание: Использование фильтра лиц значительно " "снизит скорость извлечения, а его точность не гарантируется." -#: lib/cli/args_extract_convert.py:649 +#: lib/cli/args_extract_convert.py:653 msgid "" "Optionally select people you wish to process by passing in an image of that " "person. Should be a front portrait with a single person in the image. " @@ -630,7 +638,7 @@ msgstr "" "Примечание: Использование фильтра лиц значительно снизит скорость " "извлечения, а его точность не гарантируется." -#: lib/cli/args_extract_convert.py:663 +#: lib/cli/args_extract_convert.py:667 msgid "" "For use with the optional nfilter/filter files. Threshold for positive face " "recognition. Lower values are stricter. NB: Using face filter will " @@ -642,7 +650,7 @@ msgstr "" "строгими. Примечание: Использование фильтра лиц значительно снизит скорость " "извлечения, а его точность не гарантируется." -#: lib/cli/args_extract_convert.py:676 +#: lib/cli/args_extract_convert.py:680 msgid "" "The maximum number of parallel processes for performing conversion. " "Converting images is system RAM heavy so it is possible to run out of memory " @@ -660,16 +668,7 @@ msgstr "" "процессов, чем доступно в вашей системе. Если включена однопоточная " "обработка, этот параметр будет проигнорирован." -#: lib/cli/args_extract_convert.py:688 -msgid "" -"[LEGACY] This only needs to be selected if a legacy model is being loaded or " -"if there are multiple models in the model folder" -msgstr "" -"[ОТБРОШЕН] Этот параметр необходимо выбрать только в том случае, если " -"загружается устаревшая модель или если в папке моделей имеется несколько " -"моделей" - -#: lib/cli/args_extract_convert.py:697 +#: lib/cli/args_extract_convert.py:693 msgid "" "Enable On-The-Fly Conversion. NOT recommended. You should generate a clean " "alignments file for your destination video. However, if you wish you can " @@ -684,7 +683,7 @@ msgstr "" "приведет к некачественным результатам. Если файл выравнивания найден, этот " "параметр будет проигнорирован." -#: lib/cli/args_extract_convert.py:709 +#: lib/cli/args_extract_convert.py:705 msgid "" "When used with --frame-ranges outputs the unchanged frames that are not " "processed instead of discarding them." @@ -692,12 +691,20 @@ msgstr "" "При использовании с --frame-ranges выводит неизмененные кадры, которые не " "были обработаны, вместо того, чтобы отбрасывать их." -#: lib/cli/args_extract_convert.py:717 +#: lib/cli/args_extract_convert.py:713 msgid "Swap the model. Instead converting from of A -> B, converts B -> A" msgstr "" "Поменять модель местами. Вместо преобразования из A -> B, преобразуется B -> " "A" -#: lib/cli/args_extract_convert.py:723 +#: lib/cli/args_extract_convert.py:719 msgid "Disable multiprocessing. Slower but less resource intensive." msgstr "Отключение многопоточной обработки. Медленнее, но менее ресурсоемко." + +#~ msgid "" +#~ "[LEGACY] This only needs to be selected if a legacy model is being loaded " +#~ "or if there are multiple models in the model folder" +#~ msgstr "" +#~ "[ОТБРОШЕН] Этот параметр необходимо выбрать только в том случае, если " +#~ "загружается устаревшая модель или если в папке моделей имеется несколько " +#~ "моделей" diff --git a/locales/ru/LC_MESSAGES/tools.alignments.cli.mo b/locales/ru/LC_MESSAGES/tools.alignments.cli.mo index 1a4953f0e57bef7f23018bdd4189d9e4316090f3..2ab82eb43de020412e1ac1e6a1df47f176fcde0f 100644 GIT binary patch delta 1582 zcmZ`(O>7%g5S~B@G^7fG2GP)l5dsV%vFyMB0urrID^Wo?^n%2NjlFia+3Z@oFL8@1 zZEO=-l(bY>mG~=gfHJ-beHU;!lWl8_}2hiDt0;>V6`m z(XWViBL0Rrg7`b)JaGO*Jc@Vw0U{sq8sg`O4;~=8j`%HN0Z~m*k&dC@=Lw=RHXc4m z^avV#i1_?HM7Ji1zQ_4rX`-tj`dx}J{Wp)&!LhZsMIVUJtGTN zr)=zCmnTszG#S{~2YycpVfF8Bf4c}F>3kuBFLdJCvz z$HV@Dr5w~a9@}S>9e9?@%JXF=LZ_1sw?%Ho)nv%|7gN`)Y$0xQZX zPQ4W8rMToS79B54^9f(sv%Fx1^9i7nM`K5w3qb4lIS!vXE2}3I^)g2|3}6^EoRw4@mg4%{k3S&^2!5-SurQlr98XrD4w(sfb>(_invnTmOr0oO$UVjIf_n(u0= zd#FWDNA~~^MrheEZ{xkgy3e|4R!v1dH*wlEm(2ON0>zlk{|psmduCPN91IV(h~|sZS#O$JjJyr}Z;L>;B|YN%(5Y`` zMkCa3pt2>S9Dv3*Vhd^OLj2QPaSZPDFjFYOK+!&)!H$nG=som3kMKWec z-3-s{||(eFMQt#nxIm zl~{kVaY&s3{FCD9EUpam4J8A zn^YJAlZ3K6ra)z^iE76eN5n1hNdMHuZQUQ!&=h~gJF%I82Qi(61MxM>AvAhEg+bBF0;YM|hRXA7vTb!4Y_B!k;f9-?X_z|bXXa$`1eC4uc zW=l@vgxr}{9GKIuwTQ{XgUo->G)99fa~cYsBc1X1idWv8v(~p8MQejv#)}1?3;Vf0 D_Twq- diff --git a/locales/ru/LC_MESSAGES/tools.alignments.cli.po b/locales/ru/LC_MESSAGES/tools.alignments.cli.po index 07ef977..fe2ca62 100644 --- a/locales/ru/LC_MESSAGES/tools.alignments.cli.po +++ b/locales/ru/LC_MESSAGES/tools.alignments.cli.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-28 23:49+0000\n" -"PO-Revision-Date: 2024-03-29 00:08+0000\n" +"POT-Creation-Date: 2024-04-12 12:10+0100\n" +"PO-Revision-Date: 2024-04-12 12:13+0100\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru\n" @@ -65,17 +65,23 @@ msgstr "" msgid " Use the output option (-o) to process results." msgstr " Используйте опцию вывода (-o) для обработки результатов." -#: tools/alignments/cli.py:57 tools/alignments/cli.py:97 +#: tools/alignments/cli.py:58 tools/alignments/cli.py:104 msgid "processing" msgstr "обработка" -#: tools/alignments/cli.py:60 +#: tools/alignments/cli.py:61 #, python-brace-format msgid "" "R|Choose which action you want to perform. NB: All actions require an " "alignments file (-a) to be passed in.\n" "L|'draw': Draw landmarks on frames in the selected folder/video. A subfolder " "will be created within the frames folder to hold the output.{0}\n" +"L|'export': Export the contents of an alignments file to a json file. Can be " +"used for editing alignment information in external tools and then re-" +"importing by using Faceswap's Extract 'Import' plugins. Note: masks and " +"identity vectors will not be included in the exported file, so will be re-" +"generated when the json file is imported back into Faceswap. All data is " +"exported with the origin (0, 0) at the top left of the canvas.\n" "L|'extract': Re-extract faces from the source frames/video based on " "alignment data. This is a lot quicker than re-detecting faces. Can pass in " "the '-een' (--extract-every-n) parameter to only extract every nth frame." @@ -109,6 +115,13 @@ msgstr "" "требуют передачи файла выравнивания (-a).\n" "L|'draw': Нарисовать ориентиры на кадрах в выбранной папке/видео. В папке " "frames будет создана подпапка для хранения результатов.\n" +"L|'export': экспортировать содержимое файла выравнивания в файл JSON. Может " +"использоваться для редактирования информации о выравнивании во внешних " +"инструментах, а затем повторно импортируется с помощью плагинов Faceswap " +"Extract 'Import'. ПРИМЕЧАНИЕ. Маски и векторы идентификации не будут " +"включены в экспортированный файл, поэтому будут повторно сгенерированы, " +"когда файл JSON будет импортирован обратно в Faceswap. Все данные " +"экспортируются с началом координат (0, 0) в верхнем левом углу холста.\n" "L|'extract': Повторное извлечение лиц из исходных кадров/видео на основе " "данных о выравнивании. Это намного быстрее, чем повторное обнаружение лиц. " "Можно передать параметр '-een' (--extract-every-n), чтобы извлекать только " @@ -139,7 +152,7 @@ msgstr "" "L|'spatial': Выполнить пространственную и временную фильтрацию для " "сглаживания выравниваний (ЭКСПЕРИМЕНТАЛЬНО!)." -#: tools/alignments/cli.py:100 +#: tools/alignments/cli.py:107 msgid "" "R|How to output discovered items ('faces' and 'frames' only):\n" "L|'console': Print the list of frames to the screen. (DEFAULT)\n" @@ -154,12 +167,12 @@ msgstr "" "каталоге).\n" "L|'move': Переместить обнаруженные элементы в подпапку в исходном каталоге." -#: tools/alignments/cli.py:111 tools/alignments/cli.py:134 -#: tools/alignments/cli.py:141 +#: tools/alignments/cli.py:118 tools/alignments/cli.py:141 +#: tools/alignments/cli.py:148 msgid "data" msgstr "данные" -#: tools/alignments/cli.py:118 +#: tools/alignments/cli.py:125 msgid "" "Full path to the alignments file to be processed. If you have input a " "'frames_dir' and don't provide this option, the process will try to find the " @@ -173,11 +186,11 @@ msgstr "" "задания 'from-faces', когда файл выравнивания будет создан в указанной папке " "с лицами." -#: tools/alignments/cli.py:135 +#: tools/alignments/cli.py:142 msgid "Directory containing source frames that faces were extracted from." msgstr "Папка, содержащая исходные кадры, из которых были извлечены лица." -#: tools/alignments/cli.py:143 +#: tools/alignments/cli.py:150 msgid "" "R|Run the aligmnents tool on multiple sources. The following jobs support " "batch mode:\n" @@ -220,12 +233,12 @@ msgstr "" "выравнивания должен существовать в месте по умолчанию. Для всех остальных " "заданий этот параметр игнорируется." -#: tools/alignments/cli.py:169 tools/alignments/cli.py:181 -#: tools/alignments/cli.py:191 +#: tools/alignments/cli.py:176 tools/alignments/cli.py:188 +#: tools/alignments/cli.py:198 msgid "extract" msgstr "извлечение" -#: tools/alignments/cli.py:171 +#: tools/alignments/cli.py:178 msgid "" "[Extract only] Extract every 'nth' frame. This option will skip frames when " "extracting faces. For example a value of 1 will extract faces from every " @@ -235,11 +248,11 @@ msgstr "" "кадры при извлечении лиц. Например, значение 1 будет извлекать лица из " "каждого кадра, значение 10 будет извлекать лица из каждого 10-го кадра." -#: tools/alignments/cli.py:182 +#: tools/alignments/cli.py:189 msgid "[Extract only] The output size of extracted faces." msgstr "[Только извлечение] Выходной размер извлеченных лиц." -#: tools/alignments/cli.py:193 +#: tools/alignments/cli.py:200 msgid "" "[Extract only] Only extract faces that have been resized by this percent or " "more to meet the specified extract size (`-sz`, `--size`). Useful for " diff --git a/locales/tools.alignments.cli.pot b/locales/tools.alignments.cli.pot index 44f0bc6..64c40ff 100644 --- a/locales/tools.alignments.cli.pot +++ b/locales/tools.alignments.cli.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-28 23:49+0000\n" +"POT-Creation-Date: 2024-04-12 12:10+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -53,17 +53,23 @@ msgstr "" msgid " Use the output option (-o) to process results." msgstr "" -#: tools/alignments/cli.py:57 tools/alignments/cli.py:97 +#: tools/alignments/cli.py:58 tools/alignments/cli.py:104 msgid "processing" msgstr "" -#: tools/alignments/cli.py:60 +#: tools/alignments/cli.py:61 #, python-brace-format msgid "" "R|Choose which action you want to perform. NB: All actions require an " "alignments file (-a) to be passed in.\n" "L|'draw': Draw landmarks on frames in the selected folder/video. A subfolder " "will be created within the frames folder to hold the output.{0}\n" +"L|'export': Export the contents of an alignments file to a json file. Can be " +"used for editing alignment information in external tools and then re-" +"importing by using Faceswap's Extract 'Import' plugins. Note: masks and " +"identity vectors will not be included in the exported file, so will be re-" +"generated when the json file is imported back into Faceswap. All data is " +"exported with the origin (0, 0) at the top left of the canvas.\n" "L|'extract': Re-extract faces from the source frames/video based on " "alignment data. This is a lot quicker than re-detecting faces. Can pass in " "the '-een' (--extract-every-n) parameter to only extract every nth frame." @@ -94,7 +100,7 @@ msgid "" "(EXPERIMENTAL!)" msgstr "" -#: tools/alignments/cli.py:100 +#: tools/alignments/cli.py:107 msgid "" "R|How to output discovered items ('faces' and 'frames' only):\n" "L|'console': Print the list of frames to the screen. (DEFAULT)\n" @@ -104,12 +110,12 @@ msgid "" "directory." msgstr "" -#: tools/alignments/cli.py:111 tools/alignments/cli.py:134 -#: tools/alignments/cli.py:141 +#: tools/alignments/cli.py:118 tools/alignments/cli.py:141 +#: tools/alignments/cli.py:148 msgid "data" msgstr "" -#: tools/alignments/cli.py:118 +#: tools/alignments/cli.py:125 msgid "" "Full path to the alignments file to be processed. If you have input a " "'frames_dir' and don't provide this option, the process will try to find the " @@ -118,11 +124,11 @@ msgid "" "generated in the specified faces folder." msgstr "" -#: tools/alignments/cli.py:135 +#: tools/alignments/cli.py:142 msgid "Directory containing source frames that faces were extracted from." msgstr "" -#: tools/alignments/cli.py:143 +#: tools/alignments/cli.py:150 msgid "" "R|Run the aligmnents tool on multiple sources. The following jobs support " "batch mode:\n" @@ -144,23 +150,23 @@ msgid "" "ignored." msgstr "" -#: tools/alignments/cli.py:169 tools/alignments/cli.py:181 -#: tools/alignments/cli.py:191 +#: tools/alignments/cli.py:176 tools/alignments/cli.py:188 +#: tools/alignments/cli.py:198 msgid "extract" msgstr "" -#: tools/alignments/cli.py:171 +#: tools/alignments/cli.py:178 msgid "" "[Extract only] Extract every 'nth' frame. This option will skip frames when " "extracting faces. For example a value of 1 will extract faces from every " "frame, a value of 10 will extract faces from every 10th frame." msgstr "" -#: tools/alignments/cli.py:182 +#: tools/alignments/cli.py:189 msgid "[Extract only] The output size of extracted faces." msgstr "" -#: tools/alignments/cli.py:193 +#: tools/alignments/cli.py:200 msgid "" "[Extract only] Only extract faces that have been resized by this percent or " "more to meet the specified extract size (`-sz`, `--size`). Useful for " diff --git a/plugins/convert/color/avg_color.py b/plugins/convert/color/avg_color.py index 89d0bac..f620242 100644 --- a/plugins/convert/color/avg_color.py +++ b/plugins/convert/color/avg_color.py @@ -8,10 +8,32 @@ from ._base import Adjustment class Color(Adjustment): """ Adjust the mean of the color channels to be the same for the swap and old frame """ - def process(self, old_face, new_face, raw_mask): + def process(self, + old_face: np.ndarray, + new_face: np.ndarray, + raw_mask: np.ndarray) -> np.ndarray: + """ Adjust the mean of the original face and the new face to be the same + + Parameters + ---------- + old_face: :class:`numpy.ndarray` + The original face + new_face: :class:`numpy.ndarray` + The Faceswap generated face + raw_mask: :class:`numpy.ndarray` + A raw mask for including the face area only + + Returns + ------- + :class:`numpy.ndarray` + The adjusted face patch + """ for _ in [0, 1]: diff = old_face - new_face - avg_diff = np.sum(diff * raw_mask, axis=(0, 1)) - adjustment = avg_diff / np.sum(raw_mask, axis=(0, 1)) + if np.any(raw_mask): + avg_diff = np.sum(diff * raw_mask, axis=(0, 1)) + adjustment = avg_diff / np.sum(raw_mask, axis=(0, 1)) + else: + adjustment = diff new_face += adjustment return new_face diff --git a/plugins/extract/__init__.py b/plugins/extract/__init__.py index e69de29..3bffbe7 100644 --- a/plugins/extract/__init__.py +++ b/plugins/extract/__init__.py @@ -0,0 +1,4 @@ +#!/usr/bin/env python3 +""" Package for Faceswap's extraction pipeline """ +from .extract_media import ExtractMedia +from .pipeline import Extractor diff --git a/plugins/extract/_base.py b/plugins/extract/_base.py index 4abe8a5..588394a 100644 --- a/plugins/extract/_base.py +++ b/plugins/extract/_base.py @@ -15,7 +15,7 @@ from lib.multithreading import MultiThread from lib.queue_manager import queue_manager from lib.utils import GetModel, FaceswapError from ._config import Config -from .pipeline import ExtractMedia +from . import ExtractMedia if T.TYPE_CHECKING: from collections.abc import Callable, Generator, Sequence @@ -86,6 +86,18 @@ class ExtractorBatch: prediction: np.ndarray = np.array([]) data: list[dict[str, T.Any]] = field(default_factory=list) + def __repr__(self) -> str: + """ Prettier repr for debug printing """ + data = [{k: (v.shape, v.dtype) if isinstance(v, np.ndarray) else v for k, v in dat.items()} + for dat in self.data] + return (f"{self.__class__.__name__}(" + f"image={[(img.shape, img.dtype) for img in self.image]}, " + f"detected_faces={self.detected_faces}, " + f"filename={self.filename}, " + f"feed={[(f.shape, f.dtype) for f in self.feed]}, " + f"prediction=({self.prediction.shape}, {self.prediction.dtype}), " + f"data={data}") + class Extractor(): """ Extractor Plugin Object @@ -197,7 +209,7 @@ class Extractor(): """ list: Internal threads for this plugin """ self._extract_media: dict[str, ExtractMedia] = {} - """ dict: The :class:`plugins.extract.pipeline.ExtractMedia` objects currently being + """ dict: The :class:`~plugins.extract.extract_media.ExtractMedia` objects currently being processed. Stored at input for pairing back up on output of extractor process """ # << THE FOLLOWING PROTECTED ATTRIBUTES ARE SET IN PLUGIN TYPE _base.py >>> # @@ -276,6 +288,11 @@ class Extractor(): """ raise NotImplementedError + def on_completion(self) -> None: + """ Override to perform an action when the extract process has completed. By default, no + action is undertaken """ + return + def _predict(self, batch: BatchType) -> BatchType: """ **Override method** (at `` level) @@ -362,7 +379,7 @@ class Extractor(): :mod:`plugins.extract.detect._base`, :mod:`plugins.extract.align._base` or :mod:`plugins.extract.mask._base`) and should not be overridden within plugins themselves. - Get :class:`~plugins.extract.pipeline.ExtractMedia` items from the queue in batches of + Get :class:`~plugins.extract.extract_media.ExtractMedia` items from the queue in batches of :attr:`batchsize` Parameters @@ -409,11 +426,11 @@ class Extractor(): ---------- queue: :class:`queue.Queue` The input queue to the aligner. Should contain - :class:`~plugins.extract.pipeline.ExtractMedia` objects + :class:`~plugins.extract.extract_media.ExtractMedia` objects Returns ------- - :class:`~plugins.extract.pipeline.ExtractMedia` or EOF + :class:`~plugins.extract.extract_media.ExtractMedia` or EOF The next extract media object, or EOF if pipe has ended """ if self._rollover is not None: diff --git a/plugins/extract/align/_base/aligner.py b/plugins/extract/align/_base/aligner.py index 3eec920..6746daf 100644 --- a/plugins/extract/align/_base/aligner.py +++ b/plugins/extract/align/_base/aligner.py @@ -4,7 +4,7 @@ All Aligner Plugins should inherit from this class. See the override methods for which methods are required. -The plugin will receive a :class:`~plugins.extract.pipeline.ExtractMedia` object. +The plugin will receive a :class:`~plugins.extract.extract_media.ExtractMedia` object. For each source item, the plugin must pass a dict to finalize containing: @@ -24,8 +24,10 @@ import numpy as np from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module # noqa +from lib.align import LandmarkType from lib.utils import FaceswapError -from plugins.extract._base import BatchType, Extractor, ExtractMedia, ExtractorBatch +from plugins.extract import ExtractMedia +from plugins.extract._base import BatchType, ExtractorBatch, Extractor from .processing import AlignedFilter, ReAlign if T.TYPE_CHECKING: @@ -81,20 +83,13 @@ class AlignerBatch(ExtractorBatch): def __repr__(self): """ Prettier repr for debug printing """ - data = [{k: v.shape if isinstance(v, np.ndarray) else v for k, v in dat.items()} - for dat in self.data] - return ("AlignerBatch(" - f"batch_id={self.batch_id}, " - f"image={[img.shape for img in self.image]}, " - f"detected_faces={self.detected_faces}, " - f"filename={self.filename}, " - f"feed={self.feed.shape}, " - f"prediction={self.prediction.shape}, " - f"data={data}, " - f"landmarks={self.landmarks.shape}, " - f"refeeds={[feed.shape for feed in self.refeeds]}, " - f"second_pass={self.second_pass}, " - f"second_pass_masks={self.second_pass_masks})") + retval = super().__repr__() + retval += (f", batch_id={self.batch_id}, " + f"landmarks=[({self.landmarks.shape}, {self.landmarks.dtype})], " + f"refeeds={[(f.shape, f.dtype) for f in self.refeeds]}, " + f"second_pass={self.second_pass}, " + f"second_pass_masks={self.second_pass_masks})") + return retval def __post_init__(self): """ Make sure that we have been given a non-zero ID """ @@ -157,6 +152,10 @@ class Aligner(Extractor): # pylint:disable=abstract-method **kwargs) self._plugin_type = "align" self.realign_centering: CenteringType = "face" # overide for plugin specific centering + + # Override for specific landmark type: + self.landmark_type = LandmarkType.LM_2D_68 + self._eof_seen = False self._normalize_method: T.Literal["clahe", "hist", "mean"] | None = None self._re_feed = re_feed @@ -244,8 +243,8 @@ class Aligner(Extractor): # pylint:disable=abstract-method Items are returned from the ``queue`` in batches of :attr:`~plugins.extract._base.Extractor.batchsize` - Items are received as :class:`~plugins.extract.pipeline.ExtractMedia` objects and converted - to ``dict`` for internal processing. + Items are received as :class:`~plugins.extract.extract_media.ExtractMedia` objects and + converted to ``dict`` for internal processing. To ensure consistent batch sizes for aligner the items are split into separate items for each :class:`~lib.align.DetectedFace` object. @@ -317,10 +316,6 @@ class Aligner(Extractor): # pylint:disable=abstract-method else: logger.debug(item) - # TODO Move to end of process not beginning - if exhausted: - self._filter.output_counts() - return exhausted, batch def faces_to_feed(self, faces: np.ndarray) -> np.ndarray: @@ -354,7 +349,7 @@ class Aligner(Extractor): # pylint:disable=abstract-method Yields ------ - :class:`~plugins.extract.pipeline.ExtractMedia` + :class:`~plugins.extract.extract_media.ExtractMedia` The :attr:`DetectedFaces` list will be populated for this class with the bounding boxes and landmarks for the detected faces found in the frame. """ @@ -388,6 +383,10 @@ class Aligner(Extractor): # pylint:disable=abstract-method yield output self._re_align.untrack_batch(batch.batch_id) + def on_completion(self) -> None: + """ Output the filter counts when process has completed """ + self._filter.output_counts() + # <<< PROTECTED METHODS >>> # # << PROCESS_INPUT WRAPPER >> def _get_adjusted_boxes(self, original_boxes: np.ndarray) -> np.ndarray: @@ -584,7 +583,7 @@ class Aligner(Extractor): # pylint:disable=abstract-method if not all_filtered: feed = batch.refeeds[selected_idx] pred = batch.prediction[selected_idx] - data = batch.data[selected_idx] + data = batch.data[selected_idx] if batch.data else {} selected_idx += 1 else: # All resuts have been filtered out feed = pred = np.array([]) @@ -604,14 +603,15 @@ class Aligner(Extractor): # pylint:disable=abstract-method retval.append(subbatch) else: - for feed, pred, data in zip(batch.refeeds, batch.prediction, batch.data): + b_data = batch.data if batch.data else [{}] + for feed, pred, dat in zip(batch.refeeds, batch.prediction, b_data): subbatch = AlignerBatch(batch_id=batch.batch_id, image=batch.image, detected_faces=batch.detected_faces, filename=batch.filename, feed=feed, prediction=pred, - data=[data], + data=[dat], second_pass=batch.second_pass) self.process_output(subbatch) retval.append(subbatch) diff --git a/plugins/extract/align/external.py b/plugins/extract/align/external.py new file mode 100644 index 0000000..929e9b1 --- /dev/null +++ b/plugins/extract/align/external.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +""" Import 68 point landmarks or ROI boxes from a json file """ +import logging +import typing as T +import os +import re + +import numpy as np + +from lib.align import EXTRACT_RATIOS, LandmarkType +from lib.utils import FaceswapError, IMAGE_EXTENSIONS + +from ._base import BatchType, Aligner, AlignerBatch + +logger = logging.getLogger(__name__) + + +class Align(Aligner): + """ Import face detection bounding boxes from an external json file """ + def __init__(self, **kwargs) -> None: + kwargs["normalize_method"] = None # Disable normalization + kwargs["re_feed"] = 0 # Disable re-feed + kwargs["re_align"] = False # Disablle re-align + kwargs["disable_filter"] = True # Disable aligner filters + super().__init__(git_model_id=None, model_filename=None, **kwargs) + + self.name = "External" + self.batchsize = 16 + + self._origin: T.Literal["top-left", + "bottom-left", + "top-right", + "bottom-right"] = self.config["origin"] + + self._re_frame_no: re.Pattern = re.compile(r"\d+$") + self._is_video: bool = False + self._imported: dict[str | int, tuple[int, np.ndarray]] = {} + """dict[str | int, tuple[int, np.ndarray]]: filename as key, value of [number of faces + remaining for the frame, all landmarks in the frame] """ + + self._missing: list[str] = [] + self._roll: dict[T.Literal["bottom-left", "top-right", "bottom-right"], int] = { + "bottom-left": 3, "top-right": 1, "bottom-right": 2} + """dict[Literal["bottom-left", "top-right", "bottom-right"], int]: Amount to roll the + points by for different origins when 4 Point ROI landmarks are provided """ + + centering = self.config["4_point_centering"] + self._adjustment: float = 1. if centering is None else 1. - EXTRACT_RATIOS[centering] + """float: The amount to adjust 4 point ROI landmarks to standardize the points for a + 'head' sized extracted face """ + + def init_model(self) -> None: + """ No initialization to perform """ + logger.debug("No aligner model to initialize") + + def _check_for_video(self, filename: str) -> None: + """ Check a sample filename from the import file for a file extension to set + :attr:`_is_video` + + Parameters + ---------- + filename: str + A sample file name from the imported data + """ + logger.debug("Checking for video from '%s'", filename) + ext = os.path.splitext(filename)[-1] + if ext.lower() not in IMAGE_EXTENSIONS: + self._is_video = True + logger.debug("Set is_video to %s from extension '%s'", self._is_video, ext) + + def _get_key(self, key: str) -> str | int: + """ Obtain the key for the item in the lookup table. If the input are images, the key will + be the image filename. If the input is a video, the key will be the frame number + + Parameters + ---------- + key: str + The initial key value from import data or an import image/frame + + Returns + ------- + str | int + The filename is the input data is images, otherwise the frame number of a video + """ + if not self._is_video: + return key + original_name = os.path.splitext(key)[0] + matches = self._re_frame_no.findall(original_name) + if not matches or len(matches) > 1: + raise FaceswapError(f"Invalid import name: '{key}'. For video files, the key should " + "end with the frame number.") + retval = int(matches[0]) + logger.trace("Obtained frame number %s from key '%s'", # type:ignore[attr-defined] + retval, key) + return retval + + def _import_face(self, face: dict[str, list[int] | list[list[float]]]) -> np.ndarray: + """ Import the landmarks from a single face + + Parameters + ---------- + face: dict[str, list[int] | list[list[float]]] + An import dictionary item for a face + + Returns + ------- + :class:`numpy.ndarray` + The landmark data imported from the json file + + Raises + ------ + FaceSwapError + If the landmarks_2d key does not exist or the landmarks are in an incorrect format + """ + landmarks = face.get("landmarks_2d") + if landmarks is None: + raise FaceswapError("The provided import file is the required key 'landmarks_2d") + if len(landmarks) not in (4, 68): + raise FaceswapError("Imported 'landmarks_2d' should be either 68 facial feature " + "landmarks or 4 ROI corner locations") + retval = np.array(landmarks, dtype="float32") + if retval.shape[-1] != 2: + raise FaceswapError("Imported 'landmarks_2d' should be formatted as a list of (x, y) " + "co-ordinates") + if retval.shape[0] == 4: # Adjust ROI landmarks based on centering selected + center = np.mean(retval, axis=0) + retval = (retval - center) * self._adjustment + center + + return retval + + def import_data(self, data: dict[str, list[dict[str, list[int] | list[list[float]]]]]) -> None: + """ Import the aligner data from the json import file and set to :attr:`_imported` + + Parameters + ---------- + data: dict[str, list[dict[str, list[int] | list[list[float]]]]] + The data to be imported + """ + logger.debug("Data length: %s", len(data)) + self._check_for_video(list(data)[0]) + for key, faces in data.items(): + try: + lms = np.array([self._import_face(face) for face in faces], dtype="float32") + if not np.any(lms): + logger.trace("Skipping frame '%s' with no faces") # type:ignore[attr-defined] + continue + + store_key = self._get_key(key) + self._imported[store_key] = (lms.shape[0], lms) + except FaceswapError as err: + logger.error(str(err)) + msg = f"The imported frame key that failed was '{key}'" + raise FaceswapError(msg) from err + lm_shape = set(v[1].shape[1:] for v in self._imported.values() if v[0] > 0) + if len(lm_shape) > 1: + raise FaceswapError("All external data should have the same number of landmarks. " + f"Found landmarks of shape: {lm_shape}") + if (4, 2) in lm_shape: + self.landmark_type = LandmarkType.LM_2D_4 + + def process_input(self, batch: BatchType) -> None: + """ Put the filenames and original frame dimensions into `batch.feed` so they can be + collected for mapping in `.predict` + + Parameters + ---------- + batch: :class:`~plugins.extract.detect._base.AlignerBatch` + The batch to be processed by the plugin + """ + batch.feed = np.array([(self._get_key(os.path.basename(f)), i.shape[:2]) + for f, i in zip(batch.filename, batch.image)], dtype="object") + + def faces_to_feed(self, faces: np.ndarray) -> np.ndarray: + """ No action required for import plugin + + Parameters + ---------- + faces: :class:`numpy.ndarray` + The batch of faces in UINT8 format + + Returns + ------- + class: `numpy.ndarray` + the original batch of faces + """ + return faces + + def _adjust_for_origin(self, landmarks: np.ndarray, frame_dims: tuple[int, int]) -> np.ndarray: + """ Adjust the landmarks to be top-left orientated based on the selected import origin + + Parameters + ---------- + landmarks: :class:`np.ndarray` + The imported facial landmarks box at original (0, 0) origin + frame_dims: tuple[int, int] + The (rows, columns) dimensions of the original frame + + Returns + ------- + :class:`numpy.ndarray` + The adjusted landmarks box for a top-left origin + """ + if not np.any(landmarks) or self._origin == "top-left": + return landmarks + + if LandmarkType.from_shape(landmarks.shape) == LandmarkType.LM_2D_4: + landmarks = np.roll(landmarks, self._roll[self._origin], axis=0) + + if self._origin.startswith("bottom"): + landmarks[:, 1] = frame_dims[0] - landmarks[:, 1] + if self._origin.endswith("right"): + landmarks[:, 0] = frame_dims[1] - landmarks[:, 0] + + return landmarks + + def predict(self, feed: np.ndarray) -> np.ndarray: + """ Pair the input filenames to the import file + + Parameters + ---------- + feed: :class:`numpy.ndarray` + The filenames in the batch to return imported alignments for + + Returns + ------- + :class:`numpy.ndarray` + The predictions for the given filenames + """ + preds = [] + for key, frame_dims in feed: + if key not in self._imported: + self._missing.append(key) + continue + + remaining, all_lms = self._imported[key] + preds.append(self._adjust_for_origin(all_lms[all_lms.shape[0] - remaining], + frame_dims)) + + if remaining == 1: + del self._imported[key] + else: + self._imported[key] = (remaining - 1, all_lms) + + return np.array(preds, dtype="float32") + + def process_output(self, batch: BatchType) -> None: + """ Process the imported data to the landmarks attribute + + Parameters + ---------- + batch: :class:`AlignerBatch` + The current batch from the model with :attr:`predictions` populated + """ + assert isinstance(batch, AlignerBatch) + batch.landmarks = batch.prediction + logger.trace("Imported landmarks: %s", batch.landmarks) # type:ignore[attr-defined] + + def on_completion(self) -> None: + """ Output information if: + - Imported items were not matched in input data + - Input data was not matched in imported items + """ + super().on_completion() + + if self._missing: + logger.warning("[ALIGN] %s input frames could not be matched in the import file " + "'%s'. Run in verbose mode for a list of frames.", + len(self._missing), self.config["file_name"]) + logger.verbose( # type:ignore[attr-defined] + "[ALIGN] Input frames not in import file: %s", self._missing) + + if self._imported: + logger.warning("[ALIGN] %s items in the import file '%s' could not be matched to any " + "input frames. Run in verbose mode for a list of items.", + len(self._imported), self.config["file_name"]) + logger.verbose( # type:ignore[attr-defined] + "[ALIGN] import file items not in input frames: %s", list(self._imported)) diff --git a/plugins/extract/align/external_defaults.py b/plugins/extract/align/external_defaults.py new file mode 100644 index 0000000..875abd0 --- /dev/null +++ b/plugins/extract/align/external_defaults.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +""" + The default options for the faceswap Import Alignments plugin. + + Defaults files should be named _defaults.py + Any items placed into this file will automatically get added to the relevant config .ini files + within the faceswap/config folder. + + The following variables should be defined: + _HELPTEXT: A string describing what this plugin does + _DEFAULTS: A dictionary containing the options, defaults and meta information. The + dictionary should be defined as: + {: {}} + + should always be lower text. + dictionary requirements are listed below. + + The following keys are expected for the _DEFAULTS dict: + datatype: [required] A python type class. This limits the type of data that can be + provided in the .ini file and ensures that the value is returned in the + correct type to faceswap. Valid data types are: , , + , . + default: [required] The default value for this option. + info: [required] A string describing what this option does. + group: [optional]. A group for grouping options together in the GUI. If not + provided this will not group this option with any others. + choices: [optional] If this option's datatype is of then valid + selections can be defined here. This validates the option and also enables + a combobox / radio option in the GUI. + gui_radio: [optional] If are defined, this indicates that the GUI should use + radio buttons rather than a combobox to display this option. + min_max: [partial] For and data types this is required + otherwise it is ignored. Should be a tuple of min and max accepted values. + This is used for controlling the GUI slider range. Values are not enforced. + rounding: [partial] For and data types this is + required otherwise it is ignored. Used for the GUI slider. For floats, this + is the number of decimal places to display. For ints this is the step size. + fixed: [optional] [train only]. Training configurations are fixed when the model is + created, and then reloaded from the state file. Marking an item as fixed=False + indicates that this value can be changed for existing models, and will override + the value saved in the state file with the updated value in config. If not + provided this will default to True. +""" + + +_HELPTEXT = ( + "Import Aligner options.\n" + "Imports either 68 point 2D landmarks or an aligned bounding box from an external .json file." + ) + + +_DEFAULTS = { + "file_name": { + "default": "import.json", + "info": "The import file should be stored in the same folder as the video (if extracting " + "from a video file) or inside the folder of images (if importing from a folder of images)", + "datatype": str, + "choices": [], + "group": "settings", + "gui_radio": False, + "fixed": True, + }, + "origin": { + "default": "top-left", + "info": "The origin (0, 0) location of the co-ordinates system used. " + "\n\t top-left: The origin (0, 0) of the canvas is at the top left " + "corner." + "\n\t bottom-left: The origin (0, 0) of the canvas is at the bottom " + "left corner." + "\n\t top-right: The origin (0, 0) of the canvas is at the top right " + "corner." + "\n\t bottom-right: The origin (0, 0) of the canvas is at the bottom " + "right corner.", + "datatype": str, + "choices": ["top-left", "bottom-left", "top-right", "bottom-right"], + "group": "input", + "gui_radio": True + }, + "4_point_centering": { + "default": "head", + "info": "4 point ROI landmarks only. The approximate centering for the location of the " + "corner points to be imported. Default faceswap extracts are generated at 'head' " + "centering, but it is possible to pass in ROI points at a tighter centering. " + "Refer to https://github.com/deepfakes/faceswap/pull/1095 for a visual guide" + "\n\t head: The ROI points represent a loose crop enclosing the whole head." + "\n\t face: The ROI points represent a medium crop enclosing the face." + "\n\t legacy: The ROI points represent a tight crop enclosing the central face " + "area." + "\n\t none: Only required if importing 4 point ROI landmarks back into faceswap " + "having generated them from the 'alignments' tool 'export' job.", + "datatype": str, + "choices": ["head", "face", "legacy", "none"], + "group": "input", + "gui_radio": True + } + +} diff --git a/plugins/extract/detect/_base.py b/plugins/extract/detect/_base.py index a256e67..3c3221d 100644 --- a/plugins/extract/detect/_base.py +++ b/plugins/extract/detect/_base.py @@ -4,7 +4,7 @@ All Detector Plugins should inherit from this class. See the override methods for which methods are required. -The plugin will receive a :class:`~plugins.extract.pipeline.ExtractMedia` object. +The plugin will receive a :class:`~plugins.extract.extract_media.ExtractMedia` object. For each source frame, the plugin must pass a dict to finalize containing: @@ -30,7 +30,7 @@ from lib.align import DetectedFace from lib.utils import FaceswapError from plugins.extract._base import BatchType, Extractor, ExtractorBatch -from plugins.extract.pipeline import ExtractMedia +from plugins.extract import ExtractMedia if T.TYPE_CHECKING: from collections.abc import Generator @@ -62,6 +62,15 @@ class DetectorBatch(ExtractorBatch): pad: list[tuple[int, int]] = field(default_factory=list) initial_feed: np.ndarray = np.array([]) + def __repr__(self): + """ Prettier repr for debug printing """ + retval = super().__repr__() + retval += (f", rotation_matrix={self.rotation_matrix}, " + f"scale={self.scale}, " + f"pad={self.pad}, " + f"initial_feed=({self.initial_feed.shape}, {self.initial_feed.dtype})") + return retval + class Detector(Extractor): # pylint:disable=abstract-method """ Detector Object @@ -123,8 +132,8 @@ class Detector(Extractor): # pylint:disable=abstract-method def get_batch(self, queue: Queue) -> tuple[bool, DetectorBatch]: """ Get items for inputting to the detector plugin in batches - Items are received as :class:`~plugins.extract.pipeline.ExtractMedia` objects and converted - to ``dict`` for internal processing. + Items are received as :class:`~plugins.extract.extract_media.ExtractMedia` objects and + converted to ``dict`` for internal processing. Items are returned from the ``queue`` in batches of :attr:`~plugins.extract._base.Extractor.batchsize` @@ -199,7 +208,7 @@ class Detector(Extractor): # pylint:disable=abstract-method Yields ------ - :class:`~plugins.extract.pipeline.ExtractMedia` + :class:`~plugins.extract.extract_media.ExtractMedia` The :attr:`DetectedFaces` list will be populated for this class with the bounding boxes for the detected faces found in the frame. """ @@ -342,7 +351,7 @@ class Detector(Extractor): # pylint:disable=abstract-method Parameters ---------- - item: :class:`plugins.extract.pipeline.ExtractMedia` + item: :class:`~plugins.extract.extract_media.ExtractMedia` The input item from the pipeline Returns diff --git a/plugins/extract/detect/external.py b/plugins/extract/detect/external.py new file mode 100644 index 0000000..98876e2 --- /dev/null +++ b/plugins/extract/detect/external.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +""" Import face detection ROI boxes from a json file """ +from __future__ import annotations + +import logging +import os +import re +import typing as T + +import numpy as np + +from lib.align import AlignedFace +from lib.utils import FaceswapError, IMAGE_EXTENSIONS + +from ._base import Detector + +if T.TYPE_CHECKING: + from lib.align import DetectedFace + from plugins.extract import ExtractMedia + from ._base import BatchType + +logger = logging.getLogger(__name__) + + +class Detect(Detector): + """ Import face detection bounding boxes from an external json file """ + def __init__(self, **kwargs) -> None: + kwargs["rotation"] = None # Disable rotation + kwargs["min_size"] = 0 # Disable min_size + super().__init__(git_model_id=None, model_filename=None, **kwargs) + + self.name = "External" + self.batchsize = 16 + + self._origin: T.Literal["top-left", + "bottom-left", + "top-right", + "bottom-right"] = self.config["origin"] + + self._re_frame_no: re.Pattern = re.compile(r"\d+$") + self._missing: list[str] = [] + self._log_once = True + self._is_video = False + self._imported: dict[str | int, np.ndarray] = {} + """dict[str | int, np.ndarray]: The imported data from external .json file""" + + def init_model(self) -> None: + """ No initialization to perform """ + logger.debug("No detector model to initialize") + + def _compile_detection_image(self, item: ExtractMedia + ) -> tuple[np.ndarray, float, tuple[int, int]]: + """ Override _compile_detection_image method, to obtain the source frame dimensions + + Parameters + ---------- + item: :class:`~plugins.extract.extract_media.ExtractMedia` + The input item from the pipeline + + Returns + ------- + image: :class:`numpy.ndarray` + dummy empty array + scale: float + The scaling factor for the image (1.0) + pad: int + The amount of padding applied to the image (0, 0) + """ + return np.array(item.image_shape[:2], dtype="int64"), 1.0, (0, 0) + + def _check_for_video(self, filename: str) -> None: + """ Check a sample filename from the import file for a file extension to set + :attr:`_is_video` + + Parameters + ---------- + filename: str + A sample file name from the imported data + """ + logger.debug("Checking for video from '%s'", filename) + ext = os.path.splitext(filename)[-1] + if ext.lower() not in IMAGE_EXTENSIONS: + self._is_video = True + logger.debug("Set is_video to %s from extension '%s'", self._is_video, ext) + + def _get_key(self, key: str) -> str | int: + """ Obtain the key for the item in the lookup table. If the input are images, the key will + be the image filename. If the input is a video, the key will be the frame number + + Parameters + ---------- + key: str + The initial key value from import data or an import image/frame + + Returns + ------- + str | int + The filename is the input data is images, otherwise the frame number of a video + """ + if not self._is_video: + return key + original_name = os.path.splitext(key)[0] + matches = self._re_frame_no.findall(original_name) + if not matches or len(matches) > 1: + raise FaceswapError(f"Invalid import name: '{key}'. For video files, the key should " + "end with the frame number.") + retval = int(matches[0]) + logger.trace("Obtained frame number %s from key '%s'", # type:ignore[attr-defined] + retval, key) + return retval + + @classmethod + def _bbox_from_detected(cls, bounding_box: list[int]) -> np.ndarray: + """ Import the detected face roi from a `detected` item in the import file + + Parameters + ---------- + bounding_box: list[int] + a bounding box contained within the import file + + Returns + ------- + :class:`numpy.ndarray` + The "left", "top", "right", "bottom" bounding box for the face + + Raises + ------ + FaceSwapError + If the number of bounding box co-ordinates is incorrect + """ + if len(bounding_box) != 4: + raise FaceswapError("Imported 'detected' bounding boxes should be a list of 4 numbers " + "representing the 'left', 'top', 'right', `bottom` of a face.") + return np.rint(bounding_box) + + def _validate_landmarks(self, landmarks: list[list[float]]) -> np.ndarray: + """ Validate that the there are 4 or 68 landmarks and are a complete list of (x, y) + co-ordinates + + Parameters + ---------- + landmarks: list[float] + The 4 point ROI or 68 point 2D landmarks that are being imported + + Returns + ------- + :class:`numpy.ndarray` + The original landmarks as a numpy array + + Raises + ------ + FaceSwapError + If the landmarks being imported are not correct + """ + if len(landmarks) not in (4, 68): + raise FaceswapError("Imported 'landmarks_2d' should be either 68 facial feature " + "landmarks or 4 ROI corner locations") + retval = np.array(landmarks, dtype="float32") + if retval.shape[-1] != 2: + raise FaceswapError("Imported 'landmarks_2d' should be formatted as a list of (x, y) " + "co-ordinates") + return retval + + def _bbox_from_landmarks2d(self, landmarks: list[list[float]]) -> np.ndarray: + """ Import the detected face roi by estimating from imported landmarks + + Parameters + ---------- + landmarks: list[float] + The 4 point ROI or 68 point 2D landmarks that are being imported + + Returns + ------- + :class:`numpy.ndarray` + The "left", "top", "right", "bottom" bounding box for the face + """ + n_landmarks = self._validate_landmarks(landmarks) + face = AlignedFace(n_landmarks, centering="legacy", coverage_ratio=0.75) + return np.concatenate([np.min(face.original_roi, axis=0), + np.max(face.original_roi, axis=0)]) + + def _import_frame_face(self, + face: dict[str, list[int] | list[list[float]]], + align_origin: T.Literal["top-left", + "bottom-left", + "top-right", + "bottom-right"] | None) -> np.ndarray: + """ Import a detected face ROI from the import file + + Parameters + ---------- + face: dict[str, list[int] | list[list[float]]] + The data that exists within the import file for the frame + align_origin: Literal["top-left", "bottom-left", "top-right", "bottom-right"] | None + The origin of the imported aligner data. Used if the detected ROI is being estimated + from imported aligner data + + Returns + ------- + :class:`numpy.ndarray` + The "left", "top", "right", "bottom" bounding box for the face + + Raises + ------ + FaceSwapError + If the required keys for the bounding boxes are not present for the face + """ + if "detected" in face: + return self._bbox_from_detected(T.cast(list[int], face["detected"])) + if "landmarks_2d" in face: + if self._log_once and align_origin is None: + logger.warning("You are importing Detection data, but have only provided " + "Alignment data. This is most likely incorrect and will lead " + "to poor results") + self._log_once = False + + if self._log_once and align_origin is not None and align_origin != self._origin: + logger.info("Updating Detect origin from Aligner config to '%s'", align_origin) + self._origin = align_origin + self._log_once = False + + return self._bbox_from_landmarks2d(T.cast(list[list[float]], face["landmarks_2d"])) + + raise FaceswapError("The provided import file is missing both of the required keys " + "'detected' and 'landmarks_2d") + + def import_data(self, + data: dict[str, list[dict[str, list[int] | list[list[float]]]]], + align_origin: T.Literal["top-left", + "bottom-left", + "top-right", + "bottom-right"] | None) -> None: + """ Import the detection data from the json import file and set to :attr:`_imported` + + Parameters + ---------- + data: dict[str, list[dict[str, list[int] | list[list[float]]]]] + The data to be imported + align_origin: Literal["top-left", "bottom-left", "top-right", "bottom-right"] | None + The origin of the imported aligner data. Used if the detected ROI is being estimated + from imported aligner data + """ + logger.debug("Data length: %s, align_origin: %s", len(data), align_origin) + self._check_for_video(list(data)[0]) + for key, faces in data.items(): + try: + store_key = self._get_key(key) + self._imported[store_key] = np.array([self._import_frame_face(face, align_origin) + for face in faces], dtype="int32") + except FaceswapError as err: + logger.error(str(err)) + msg = f"The imported frame key that failed was '{key}'" + raise FaceswapError(msg) from err + + def process_input(self, batch: BatchType) -> None: + """ Put the lookup key into `batch.feed` so they can be collected for mapping in `.predict` + + Parameters + ---------- + batch: :class:`~plugins.extract.detect._base.DetectorBatch` + The batch to be processed by the plugin + """ + batch.feed = np.array([(self._get_key(os.path.basename(f)), i) + for f, i in zip(batch.filename, batch.image)], dtype="object") + + def _adjust_for_origin(self, box: np.ndarray, frame_dims: tuple[int, int]) -> np.ndarray: + """ Adjust the bounding box to be top-left orientated based on the selected import origin + + Parameters + ---------- + box: :class:`np.ndarray` + The imported bounding box at original (0, 0) origin + frame_dims: tuple[int, int] + The (rows, columns) dimensions of the original frame + + Returns + ------- + :class:`numpy.ndarray` + The adjusted bounding box for a top-left origin + """ + if not np.any(box) or self._origin == "top-left": + return box + if self._origin.startswith("bottom"): + box[:, [1, 3]] = frame_dims[0] - box[:, [1, 3]] + if self._origin.endswith("right"): + box[:, [0, 2]] = frame_dims[1] - box[:, [0, 2]] + + return box + + def predict(self, feed: np.ndarray) -> list[np.ndarray]: # type:ignore[override] + """ Pair the input filenames to the import file + + Parameters + ---------- + feed: :class:`numpy.ndarray` + The filenames with original frame dimensions to obtain the imported bounding boxes for + + Returns + ------- + list[]:class:`numpy.ndarray`] + The bounding boxes for the given filenames + """ + self._missing.extend(f[0] for f in feed if f[0] not in self._imported) + return [self._adjust_for_origin(self._imported.pop(f[0], np.array([], dtype="int32")), + f[1]) + for f in feed] + + def process_output(self, batch: BatchType) -> None: + """ No output processing required for import plugin + + Parameters + ---------- + batch: :class:`~plugins.extract.detect._base.DetectorBatch` + The batch to be processed by the plugin + """ + logger.trace("No output processing for import plugin") # type:ignore[attr-defined] + + def _remove_zero_sized_faces(self, batch_faces: list[list[DetectedFace]] + ) -> list[list[DetectedFace]]: + """ Override _remove_zero_sized_faces to just return the faces that have been imported + + Parameters + ---------- + batch_faces: list[list[DetectedFace] + List of detected face objects + + Returns + ------- + list[list[DetectedFace] + Original list of detected face objects + """ + return batch_faces + + def on_completion(self) -> None: + """ Output information if: + - Imported items were not matched in input data + - Input data was not matched in imported items + """ + super().on_completion() + + if self._missing: + logger.warning("[DETECT] %s input frames could not be matched in the import file " + "'%s'. Run in verbose mode for a list of frames.", + len(self._missing), self.config["file_name"]) + logger.verbose( # type:ignore[attr-defined] + "[DETECT] Input frames not in import file: %s", self._missing) + + if self._imported: + logger.warning("[DETECT] %s items in the import file '%s' could not be matched to any " + "input frames. Run in verbose mode for a list of items.", + len(self._imported), self.config["file_name"]) + logger.verbose( # type:ignore[attr-defined] + "[DETECT] import file items not in input frames: %s", list(self._imported)) diff --git a/plugins/extract/detect/external_defaults.py b/plugins/extract/detect/external_defaults.py new file mode 100644 index 0000000..c444bf4 --- /dev/null +++ b/plugins/extract/detect/external_defaults.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +""" + The default options for the faceswap Import Alignments plugin. + + Defaults files should be named _defaults.py + Any items placed into this file will automatically get added to the relevant config .ini files + within the faceswap/config folder. + + The following variables should be defined: + _HELPTEXT: A string describing what this plugin does + _DEFAULTS: A dictionary containing the options, defaults and meta information. The + dictionary should be defined as: + {: {}} + + should always be lower text. + dictionary requirements are listed below. + + The following keys are expected for the _DEFAULTS dict: + datatype: [required] A python type class. This limits the type of data that can be + provided in the .ini file and ensures that the value is returned in the + correct type to faceswap. Valid data types are: , , + , . + default: [required] The default value for this option. + info: [required] A string describing what this option does. + group: [optional]. A group for grouping options together in the GUI. If not + provided this will not group this option with any others. + choices: [optional] If this option's datatype is of then valid + selections can be defined here. This validates the option and also enables + a combobox / radio option in the GUI. + gui_radio: [optional] If are defined, this indicates that the GUI should use + radio buttons rather than a combobox to display this option. + min_max: [partial] For and data types this is required + otherwise it is ignored. Should be a tuple of min and max accepted values. + This is used for controlling the GUI slider range. Values are not enforced. + rounding: [partial] For and data types this is + required otherwise it is ignored. Used for the GUI slider. For floats, this + is the number of decimal places to display. For ints this is the step size. + fixed: [optional] [train only]. Training configurations are fixed when the model is + created, and then reloaded from the state file. Marking an item as fixed=False + indicates that this value can be changed for existing models, and will override + the value saved in the state file with the updated value in config. If not + provided this will default to True. +""" + + +_HELPTEXT = ( + "Import Detector options.\n" + "Imports a detected face bounding box from an external .json file.\n" + ) + + +_DEFAULTS = { + "file_name": { + "default": "import.json", + "info": "The import file should be stored in the same folder as the video (if extracting " + "from a video file) or inside the folder of images (if importing from a folder of images)", + "datatype": str, + "choices": [], + "group": "settings", + "gui_radio": False, + "fixed": True, + }, + "origin": { + "default": "top-left", + "info": "The origin (0, 0) location of the co-ordinates system used. " + "\n\t top-left: The origin (0, 0) of the canvas is at the top left " + "corner." + "\n\t bottom-left: The origin (0, 0) of the canvas is at the bottom " + "left corner." + "\n\t top-right: The origin (0, 0) of the canvas is at the top right " + "corner." + "\n\t bottom-right: The origin (0, 0) of the canvas is at the bottom " + "right corner.", + "datatype": str, + "choices": ["top-left", "bottom-left", "top-right", "bottom-right"], + "group": "output", + "gui_radio": True + } +} diff --git a/plugins/extract/extract_media.py b/plugins/extract/extract_media.py new file mode 100644 index 0000000..b9d3f84 --- /dev/null +++ b/plugins/extract/extract_media.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +""" Object for holding and manipulating media passing through a faceswap extraction pipeline """ +from __future__ import annotations +import logging +import typing as T + +import cv2 + +from lib.logger import parse_class_init + +if T.TYPE_CHECKING: + import numpy as np + from lib.align.alignments import PNGHeaderSourceDict + from lib.align.detected_face import DetectedFace + +logger = logging.getLogger(__name__) + + +class ExtractMedia: + """ An object that passes through the :class:`~plugins.extract.pipeline.Extractor` pipeline. + + Parameters + ---------- + filename: str + The base name of the original frame's filename + image: :class:`numpy.ndarray` + The original frame or a faceswap aligned face image + detected_faces: list, optional + A list of :class:`~lib.align.DetectedFace` objects. Detected faces can be added + later with :func:`add_detected_faces`. Setting ``None`` will default to an empty list. + Default: ``None`` + is_aligned: bool, optional + ``True`` if the :attr:`image` is an aligned faceswap image otherwise ``False``. Used for + face filtering with vggface2. Aligned faceswap images will automatically skip detection, + alignment and masking. Default: ``False`` + """ + + def __init__(self, + filename: str, + image: np.ndarray, + detected_faces: list[DetectedFace] | None = None, + is_aligned: bool = False) -> None: + logger.trace(parse_class_init(locals())) # type:ignore[attr-defined] + self._filename = filename + self._image: np.ndarray | None = image + self._image_shape = T.cast(tuple[int, int, int], image.shape) + self._detected_faces: list[DetectedFace] = ([] if detected_faces is None + else detected_faces) + self._is_aligned = is_aligned + self._frame_metadata: PNGHeaderSourceDict | None = None + self._sub_folders: list[str | None] = [] + + @property + def filename(self) -> str: + """ str: The base name of the :attr:`image` filename. """ + return self._filename + + @property + def image(self) -> np.ndarray: + """ :class:`numpy.ndarray`: The source frame for this object. """ + assert self._image is not None + return self._image + + @property + def image_shape(self) -> tuple[int, int, int]: + """ tuple: The shape of the stored :attr:`image`. """ + return self._image_shape + + @property + def image_size(self) -> tuple[int, int]: + """ tuple: The (`height`, `width`) of the stored :attr:`image`. """ + return self._image_shape[:2] + + @property + def detected_faces(self) -> list[DetectedFace]: + """list: A list of :class:`~lib.align.DetectedFace` objects in the :attr:`image`. """ + return self._detected_faces + + @property + def is_aligned(self) -> bool: + """ bool. ``True`` if :attr:`image` is an aligned faceswap image otherwise ``False`` """ + return self._is_aligned + + @property + def frame_metadata(self) -> PNGHeaderSourceDict: + """ dict: The frame metadata that has been added from an aligned image. This property + should only be called after :func:`add_frame_metadata` has been called when processing + an aligned face. For all other instances an assertion error will be raised. + + Raises + ------ + AssertionError + If frame metadata has not been populated from an aligned image + """ + assert self._frame_metadata is not None + return self._frame_metadata + + @property + def sub_folders(self) -> list[str | None]: + """ list: The sub_folders that the faces should be output to. Used when binning filter + output is enabled. The list corresponds to the list of detected faces + """ + return self._sub_folders + + def get_image_copy(self, color_format: T.Literal["BGR", "RGB", "GRAY"]) -> np.ndarray: + """ Get a copy of the image in the requested color format. + + Parameters + ---------- + color_format: ['BGR', 'RGB', 'GRAY'] + The requested color format of :attr:`image` + + Returns + ------- + :class:`numpy.ndarray`: + A copy of :attr:`image` in the requested :attr:`color_format` + """ + logger.trace("Requested color format '%s' for frame '%s'", # type:ignore[attr-defined] + color_format, self._filename) + image = getattr(self, f"_image_as_{color_format.lower()}")() + return image + + def add_detected_faces(self, faces: list[DetectedFace]) -> None: + """ Add detected faces to the object. Called at the end of each extraction phase. + + Parameters + ---------- + faces: list + A list of :class:`~lib.align.DetectedFace` objects + """ + logger.trace("Adding detected faces for filename: '%s'. " # type:ignore[attr-defined] + "(faces: %s, lrtb: %s)", self._filename, faces, + [(face.left, face.right, face.top, face.bottom) for face in faces]) + self._detected_faces = faces + + def add_sub_folders(self, folders: list[str | None]) -> None: + """ Add detected faces to the object. Called at the end of each extraction phase. + + Parameters + ---------- + folders: list + A list of str sub folder names or ``None`` if no sub folder is required. Should + correspond to the detected faces list + """ + logger.trace("Adding sub folders for filename: '%s'. " # type:ignore[attr-defined] + "(folders: %s)", self._filename, folders,) + self._sub_folders = folders + + def remove_image(self) -> None: + """ Delete the image and reset :attr:`image` to ``None``. + + Required for multi-phase extraction to avoid the frames stacking RAM. + """ + logger.trace("Removing image for filename: '%s'", # type:ignore[attr-defined] + self._filename) + del self._image + self._image = None + + def set_image(self, image: np.ndarray) -> None: + """ Add the image back into :attr:`image` + + Required for multi-phase extraction adds the image back to this object. + + Parameters + ---------- + image: :class:`numpy.ndarry` + The original frame to be re-applied to for this :attr:`filename` + """ + logger.trace("Reapplying image: (filename: `%s`, " # type:ignore[attr-defined] + "image shape: %s)", self._filename, image.shape) + self._image = image + + def add_frame_metadata(self, metadata: PNGHeaderSourceDict) -> None: + """ Add the source frame metadata from an aligned PNG's header data. + + metadata: dict + The contents of the 'source' field in the PNG header + """ + logger.trace("Adding PNG Source data for '%s': %s", # type:ignore[attr-defined] + self._filename, metadata) + dims = T.cast(tuple[int, int], metadata["source_frame_dims"]) + self._image_shape = (*dims, 3) + self._frame_metadata = metadata + + def _image_as_bgr(self) -> np.ndarray: + """ Get a copy of the source frame in BGR format. + + Returns + ------- + :class:`numpy.ndarray`: + A copy of :attr:`image` in BGR color format """ + return self.image[..., :3].copy() + + def _image_as_rgb(self) -> np.ndarray: + """ Get a copy of the source frame in RGB format. + + Returns + ------- + :class:`numpy.ndarray`: + A copy of :attr:`image` in RGB color format """ + return self.image[..., 2::-1].copy() + + def _image_as_gray(self) -> np.ndarray: + """ Get a copy of the source frame in gray-scale format. + + Returns + ------- + :class:`numpy.ndarray`: + A copy of :attr:`image` in gray-scale color format """ + return cv2.cvtColor(self.image.copy(), cv2.COLOR_BGR2GRAY) diff --git a/plugins/extract/mask/_base.py b/plugins/extract/mask/_base.py index 0284a60..8b5d71e 100644 --- a/plugins/extract/mask/_base.py +++ b/plugins/extract/mask/_base.py @@ -5,7 +5,7 @@ Plugins should inherit from this class See the override methods for which methods are required. -The plugin will receive a :class:`~plugins.extract.pipeline.ExtractMedia` object. +The plugin will receive a :class:`~plugins.extract.extract_media.ExtractMedia` object. For each source item, the plugin must pass a dict to finalize containing: @@ -23,9 +23,10 @@ import numpy as np from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module # noqa -from lib.align import AlignedFace, transform_image +from lib.align import AlignedFace, LandmarkType, transform_image from lib.utils import FaceswapError -from plugins.extract._base import BatchType, Extractor, ExtractorBatch, ExtractMedia +from plugins.extract import ExtractMedia +from plugins.extract._base import BatchType, ExtractorBatch, Extractor if T.TYPE_CHECKING: from collections.abc import Generator @@ -79,6 +80,8 @@ class Masker(Extractor): # pylint:disable=abstract-method plugins.extract.align._base : Aligner parent class for extraction plugins. """ + _logged_lm_count_once = False + def __init__(self, git_model_id: int | None = None, model_filename: str | None = None, @@ -94,20 +97,41 @@ class Masker(Extractor): # pylint:disable=abstract-method self.input_size = 256 # Override for model specific input_size self.coverage_ratio = 1.0 # Override for model specific coverage_ratio + # Override if a specific type of landmark data is required: + self.landmark_type: LandmarkType | None = None + self._plugin_type = "mask" self._storage_name = self.__module__.rsplit(".", maxsplit=1)[-1].replace("_", "-") self._storage_centering: CenteringType = "face" # Centering to store the mask at self._storage_size = 128 # Size to store masks at. Leave this at default logger.debug("Initialized %s", self.__class__.__name__) + def _maybe_log_warning(self, face: AlignedFace) -> None: + """ Log a warning, once, if we do not have full facial landmarks + + Parameters + ---------- + face: :class:`~lib.align.aligned_face.AlignedFace` + The aligned face object to test the landmark type for + """ + if face.landmark_type != LandmarkType.LM_2D_4 or self._logged_lm_count_once: + return + + msg = "are likely to be sub-standard" + msg = "can not be be generated" if self.name in ("Components", "Extended") else msg + + logger.warning("Extracted faces do not contain facial landmark data. '%s' masks %s.", + self.name, msg) + self._logged_lm_count_once = True + def get_batch(self, queue: Queue) -> tuple[bool, MaskerBatch]: """ Get items for inputting into the masker from the queue in batches Items are returned from the ``queue`` in batches of :attr:`~plugins.extract._base.Extractor.batchsize` - Items are received as :class:`~plugins.extract.pipeline.ExtractMedia` objects and converted - to ``dict`` for internal processing. + Items are received as :class:`~plugins.extract.extract_media.ExtractMedia` objects and + converted to ``dict`` for internal processing. To ensure consistent batch sizes for masker the items are split into separate items for each :class:`~lib.align.DetectedFace` object. @@ -163,6 +187,8 @@ class Masker(Extractor): # pylint:disable=abstract-method dtype="float32", is_aligned=item.is_aligned) + self._maybe_log_warning(feed_face) + assert feed_face.face is not None if not item.is_aligned: # Split roi mask from feed face alpha channel @@ -240,7 +266,7 @@ class Masker(Extractor): # pylint:disable=abstract-method Yields ------ - :class:`~plugins.extract.pipeline.ExtractMedia` + :class:`~plugins.extract.extract_media.ExtractMedia` The :attr:`DetectedFaces` list will be populated for this class with the bounding boxes, landmarks and masks for the detected faces found in the frame. """ @@ -249,6 +275,10 @@ class Masker(Extractor): # pylint:disable=abstract-method batch.detected_faces, batch.feed_faces, batch.roi_masks): + if self.name in ("Components", "Extended") and not np.any(mask): + # Components/Extended masks can return empty when called from the manual tool with + # 4 Point ROI landmarks + continue self._crop_out_of_bounds(mask, roi_mask) face.add_mask(self._storage_name, mask, diff --git a/plugins/extract/mask/components.py b/plugins/extract/mask/components.py index a787023..0a71af4 100644 --- a/plugins/extract/mask/components.py +++ b/plugins/extract/mask/components.py @@ -7,6 +7,8 @@ import typing as T import cv2 import numpy as np +from lib.align import LandmarkType + from ._base import BatchType, Masker if T.TYPE_CHECKING: @@ -26,6 +28,7 @@ class Mask(Masker): self.vram = 0 # Doesn't use GPU self.vram_per_batch = 0 self.batchsize = 1 + self.landmark_type = LandmarkType.LM_2D_68 def init_model(self) -> None: logger.debug("No mask model to initialize") @@ -40,6 +43,10 @@ class Mask(Masker): faces: list[AlignedFace] = feed[1] feed = feed[0] for mask, face in zip(feed, faces): + if LandmarkType.from_shape(face.landmarks.shape) != self.landmark_type: + # Called from the manual tool. # TODO This will only work with BS1 + feed = np.zeros_like(feed) + continue parts = self.parse_parts(np.array(face.landmarks)) for item in parts: a_item = np.rint(np.concatenate(item)).astype("int32") diff --git a/plugins/extract/mask/extended.py b/plugins/extract/mask/extended.py index 6332383..d6970cb 100644 --- a/plugins/extract/mask/extended.py +++ b/plugins/extract/mask/extended.py @@ -6,6 +6,9 @@ import typing as T import cv2 import numpy as np + +from lib.align import LandmarkType + from ._base import BatchType, Masker logger = logging.getLogger(__name__) @@ -25,6 +28,7 @@ class Mask(Masker): self.vram = 0 # Doesn't use GPU self.vram_per_batch = 0 self.batchsize = 1 + self.landmark_type = LandmarkType.LM_2D_68 def init_model(self) -> None: logger.debug("No mask model to initialize") @@ -39,6 +43,10 @@ class Mask(Masker): faces: list[AlignedFace] = feed[1] feed = feed[0] for mask, face in zip(feed, faces): + if LandmarkType.from_shape(face.landmarks.shape) != self.landmark_type: + # Called from the manual tool. # TODO This will only work with BS1 + feed = np.zeros_like(feed) + continue parts = self.parse_parts(np.array(face.landmarks)) for item in parts: a_item = np.rint(np.concatenate(item)).astype("int32") diff --git a/plugins/extract/pipeline.py b/plugins/extract/pipeline.py index 0cca3e5..5a05193 100644 --- a/plugins/extract/pipeline.py +++ b/plugins/extract/pipeline.py @@ -10,25 +10,27 @@ plugins either in parallel or in series, giving easy access to input and output. """ from __future__ import annotations import logging +import os import typing as T -import cv2 - +from lib.align import LandmarkType from lib.gpu_stats import GPUStats +from lib.logger import parse_class_init from lib.queue_manager import EventQueue, queue_manager, QueueEmpty -from lib.utils import get_backend +from lib.serializer import get_serializer +from lib.utils import get_backend, FaceswapError from plugins.plugin_loader import PluginLoader if T.TYPE_CHECKING: - import numpy as np from collections.abc import Generator - from lib.align.alignments import PNGHeaderSourceDict - from lib.align.detected_face import DetectedFace - from plugins.extract._base import Extractor as PluginExtractor - from plugins.extract.detect._base import Detector - from plugins.extract.align._base import Aligner - from plugins.extract.mask._base import Masker - from plugins.extract.recognition._base import Identity + from ._base import Extractor as PluginExtractor + from .align._base import Aligner + from .align.external import Align as AlignImport + from .detect._base import Detector + from .detect.external import Detect as DetectImport + from .mask._base import Masker + from .recognition._base import Identity + from . import ExtractMedia logger = logging.getLogger(__name__) _INSTANCES = -1 # Tracking for multiple instances of pipeline @@ -110,12 +112,7 @@ class Extractor(): re_feed: int = 0, re_align: bool = False, disable_filter: bool = False) -> None: - logger.debug("Initializing %s: (detector: %s, aligner: %s, masker: %s, recognition: %s, " - "configfile: %s, multiprocess: %s, exclude_gpus: %s, rotate_images: %s, " - "min_size: %s, normalize_method: %s, re_feed: %s, re_align: %s, " - "disable_filter: %s)", self.__class__.__name__, detector, aligner, masker, - recognition, configfile, multiprocess, exclude_gpus, rotate_images, min_size, - normalize_method, re_feed, re_align, disable_filter) + logger.debug(parse_class_init(locals())) self._instance = _get_instance() maskers = [T.cast(str | None, masker)] if not isinstance(masker, list) else T.cast(list[str | None], @@ -128,7 +125,7 @@ class Extractor(): # TODO Calculate scaling for more plugins than currently exist in _parallel_scaling self._scaling_fallback = 0.4 self._vram_stats = self._get_vram_stats() - self._detect = self._load_detect(detector, rotate_images, min_size, configfile) + self._detect = self._load_detect(detector, aligner, rotate_images, min_size, configfile) self._align = self._load_align(aligner, configfile, normalize_method, @@ -212,7 +209,7 @@ class Extractor(): >>> extractor.input_queue.put(extract_media) """ retval = self._phase_index == len(self._phases) - 1 - logger.trace(retval) # type: ignore + logger.trace(retval) # type:ignore[attr-defined] return retval @property @@ -266,7 +263,7 @@ class Extractor(): for phase in self._current_phase: self._launch_plugin(phase) - def detected_faces(self) -> Generator["ExtractMedia", None, None]: + def detected_faces(self) -> Generator[ExtractMedia, None, None]: """ Generator that returns results, frame by frame from the extraction pipeline This is the exit point for the extraction pipeline and is used to obtain the output @@ -274,7 +271,7 @@ class Extractor(): Yields ------ - faces: :class:`ExtractMedia` + faces: :class:`~plugins.extract.extract_media.ExtractMedia` The populated extracted media object. Example @@ -300,11 +297,89 @@ class Extractor(): self._join_threads() if self.final_pass: + for plugin in self._all_plugins: + plugin.on_completion() logger.debug("Detection Complete") else: self._phase_index += 1 logger.debug("Switching to phase: %s", self._current_phase) + def _disable_lm_maskers(self) -> None: + """ Disable any 68 point landmark based maskers if alignment data is not 2D 68 + point landmarks and update the process flow/phases accordingly """ + logger.warning("Alignment data is not 68 point 2D landmarks. Some Faceswap functionality " + "will be unavailable for these faces") + + rem_maskers = [m.name for m in self._mask + if m is not None and m.landmark_type == LandmarkType.LM_2D_68] + self._mask = [m for m in self._mask if m is None or m.name not in rem_maskers] + + self._flow = [ + item for item in self._flow + if not item.startswith("mask") + or item.startswith("mask") and int(item.rsplit("_", maxsplit=1)[-1]) < len(self._mask)] + + self._phases = [[s for s in p if s in self._flow] for p in self._phases + if any(t in p for t in self._flow)] + + for queue in self._queues: + queue_manager.del_queue(queue) + del self._queues + self._queues = self._add_queues() + + logger.warning("The following maskers have been disabled due to unsupported landmarks: %s", + rem_maskers) + + def import_data(self, input_location: str) -> None: + """ Import json data to the detector and/or aligner if 'import' plugin has been selected + + Parameters + ---------- + input_location: str + Full path to the input location for the extract process + """ + assert self._detect is not None + import_plugins: list[DetectImport | AlignImport] = [ + p for p in (self._detect, self.aligner) # type:ignore[misc] + if T.cast(str, p.name).lower() == "external"] + + if not import_plugins: + return + + align_origin = None + assert self.aligner.name is not None + if self.aligner.name.lower() == "external": + align_origin = self.aligner.config["origin"] + + logger.info("Importing external data for %s from json file...", + " and ".join([p.__class__.__name__ for p in import_plugins])) + + folder = input_location + folder = folder if os.path.isdir(folder) else os.path.dirname(folder) + + last_fname = "" + is_68_point = True + for plugin in import_plugins: + plugin_type = plugin.__class__.__name__ + path = os.path.join(folder, plugin.config["file_name"]) + if not os.path.isfile(path): + raise FaceswapError(f"{plugin_type} import file could not be found at '{path}'") + + if path != last_fname: # Different import file for aligner data + last_fname = path + data = get_serializer("json").load(path) + + if plugin_type == "Detect": + plugin.import_data(data, align_origin) # type:ignore[call-arg] + else: + plugin.import_data(data) # type:ignore[call-arg] + is_68_point = plugin.landmark_type == LandmarkType.LM_2D_68 # type:ignore[union-attr] # noqa:E501 # pylint:disable="line-too-long" + + if not is_68_point: + self._disable_lm_maskers() + + logger.info("Imported external data") + # <<< INTERNAL METHODS >>> # @property def _parallel_scaling(self) -> dict[int, float]: @@ -616,14 +691,40 @@ class Extractor(): def _load_detect(self, detector: str | None, + aligner: str | None, rotation: str | None, min_size: int, configfile: str | None) -> Detector | None: - """ Set global arguments and load detector plugin """ + """ Set global arguments and load detector plugin + + Parameters + ---------- + detector: str | None + The name of the face detection plugin to use. ``None`` for no detection + aligner: str | None + The name of the face aligner plugin to use. ``None`` for no aligner + rotation: str | None + The rotation to perform on detection. ``None`` for no rotation + min_size: int + The minimum size of detected faces to accept + configfile: str | None + Full path to a custom config file to use. ``None`` for default config + + Returns + ------- + :class:`~plugins.extract.detect._base.Detector` | None + The face detection plugin to use, or ``None`` if no detection to be performed + """ if detector is None or detector.lower() == "none": logger.debug("No detector selected. Returning None") return None detector_name = detector.replace("-", "_").lower() + + if aligner == "external" and detector_name != "external": + logger.warning("Unsupported '%s' detector selected for 'External' aligner. Switching " + "detector to 'External'", detector_name) + detector_name = aligner + logger.debug("Loading Detector: '%s'", detector_name) plugin = PluginLoader.get_detector(detector_name)(exclude_gpus=self._exclude_gpus, rotation=rotation, @@ -775,198 +876,3 @@ class Extractor(): """ Check all threads for errors and raise if one occurs """ for plugin in self._active_plugins: plugin.check_and_raise_error() - - -class ExtractMedia(): - """ An object that passes through the :class:`~plugins.extract.pipeline.Extractor` pipeline. - - Parameters - ---------- - filename: str - The base name of the original frame's filename - image: :class:`numpy.ndarray` - The original frame or a faceswap aligned face image - detected_faces: list, optional - A list of :class:`~lib.align.DetectedFace` objects. Detected faces can be added - later with :func:`add_detected_faces`. Setting ``None`` will default to an empty list. - Default: ``None`` - is_aligned: bool, optional - ``True`` if the :attr:`image` is an aligned faceswap image otherwise ``False``. Used for - face filtering with vggface2. Aligned faceswap images will automatically skip detection, - alignment and masking. Default: ``False`` - """ - - def __init__(self, - filename: str, - image: np.ndarray, - detected_faces: list[DetectedFace] | None = None, - is_aligned: bool = False) -> None: - logger.trace("Initializing %s: (filename: '%s', image shape: %s, " # type: ignore - "detected_faces: %s, is_aligned: %s)", self.__class__.__name__, filename, - image.shape, detected_faces, is_aligned) - self._filename = filename - self._image: np.ndarray | None = image - self._image_shape = T.cast(tuple[int, int, int], image.shape) - self._detected_faces: list[DetectedFace] = ([] if detected_faces is None - else detected_faces) - self._is_aligned = is_aligned - self._frame_metadata: PNGHeaderSourceDict | None = None - self._sub_folders: list[str | None] = [] - - @property - def filename(self) -> str: - """ str: The base name of the :attr:`image` filename. """ - return self._filename - - @property - def image(self) -> np.ndarray: - """ :class:`numpy.ndarray`: The source frame for this object. """ - assert self._image is not None - return self._image - - @property - def image_shape(self) -> tuple[int, int, int]: - """ tuple: The shape of the stored :attr:`image`. """ - return self._image_shape - - @property - def image_size(self) -> tuple[int, int]: - """ tuple: The (`height`, `width`) of the stored :attr:`image`. """ - return self._image_shape[:2] - - @property - def detected_faces(self) -> list[DetectedFace]: - """list: A list of :class:`~lib.align.DetectedFace` objects in the :attr:`image`. """ - return self._detected_faces - - @property - def is_aligned(self) -> bool: - """ bool. ``True`` if :attr:`image` is an aligned faceswap image otherwise ``False`` """ - return self._is_aligned - - @property - def frame_metadata(self) -> PNGHeaderSourceDict: - """ dict: The frame metadata that has been added from an aligned image. This property - should only be called after :func:`add_frame_metadata` has been called when processing - an aligned face. For all other instances an assertion error will be raised. - - Raises - ------ - AssertionError - If frame metadata has not been populated from an aligned image - """ - assert self._frame_metadata is not None - return self._frame_metadata - - @property - def sub_folders(self) -> list[str | None]: - """ list: The sub_folders that the faces should be output to. Used when binning filter - output is enabled. The list corresponds to the list of detected faces - """ - return self._sub_folders - - def get_image_copy(self, color_format: T.Literal["BGR", "RGB", "GRAY"]) -> np.ndarray: - """ Get a copy of the image in the requested color format. - - Parameters - ---------- - color_format: ['BGR', 'RGB', 'GRAY'] - The requested color format of :attr:`image` - - Returns - ------- - :class:`numpy.ndarray`: - A copy of :attr:`image` in the requested :attr:`color_format` - """ - logger.trace("Requested color format '%s' for frame '%s'", # type: ignore - color_format, self._filename) - image = getattr(self, f"_image_as_{color_format.lower()}")() - return image - - def add_detected_faces(self, faces: list[DetectedFace]) -> None: - """ Add detected faces to the object. Called at the end of each extraction phase. - - Parameters - ---------- - faces: list - A list of :class:`~lib.align.DetectedFace` objects - """ - logger.trace("Adding detected faces for filename: '%s'. " # type: ignore - "(faces: %s, lrtb: %s)", self._filename, faces, - [(face.left, face.right, face.top, face.bottom) for face in faces]) - self._detected_faces = faces - - def add_sub_folders(self, folders: list[str | None]) -> None: - """ Add detected faces to the object. Called at the end of each extraction phase. - - Parameters - ---------- - folders: list - A list of str sub folder names or ``None`` if no sub folder is required. Should - correspond to the detected faces list - """ - logger.trace("Adding sub folders for filename: '%s'. " # type: ignore - "(folders: %s)", self._filename, folders,) - self._sub_folders = folders - - def remove_image(self) -> None: - """ Delete the image and reset :attr:`image` to ``None``. - - Required for multi-phase extraction to avoid the frames stacking RAM. - """ - logger.trace("Removing image for filename: '%s'", self._filename) # type: ignore - del self._image - self._image = None - - def set_image(self, image: np.ndarray) -> None: - """ Add the image back into :attr:`image` - - Required for multi-phase extraction adds the image back to this object. - - Parameters - ---------- - image: :class:`numpy.ndarry` - The original frame to be re-applied to for this :attr:`filename` - """ - logger.trace("Reapplying image: (filename: `%s`, image shape: %s)", # type: ignore - self._filename, image.shape) - self._image = image - - def add_frame_metadata(self, metadata: PNGHeaderSourceDict) -> None: - """ Add the source frame metadata from an aligned PNG's header data. - - metadata: dict - The contents of the 'source' field in the PNG header - """ - logger.trace("Adding PNG Source data for '%s': %s", # type:ignore - self._filename, metadata) - dims = T.cast(tuple[int, int], metadata["source_frame_dims"]) - self._image_shape = (*dims, 3) - self._frame_metadata = metadata - - def _image_as_bgr(self) -> np.ndarray: - """ Get a copy of the source frame in BGR format. - - Returns - ------- - :class:`numpy.ndarray`: - A copy of :attr:`image` in BGR color format """ - return self.image[..., :3].copy() - - def _image_as_rgb(self) -> np.ndarray: - """ Get a copy of the source frame in RGB format. - - Returns - ------- - :class:`numpy.ndarray`: - A copy of :attr:`image` in RGB color format """ - return self.image[..., 2::-1].copy() - - def _image_as_gray(self) -> np.ndarray: - """ Get a copy of the source frame in gray-scale format. - - Returns - ------- - :class:`numpy.ndarray`: - A copy of :attr:`image` in gray-scale color format """ - return cv2.cvtColor(self.image.copy(), cv2.COLOR_BGR2GRAY) diff --git a/plugins/extract/recognition/_base.py b/plugins/extract/recognition/_base.py index 3630607..61662e6 100644 --- a/plugins/extract/recognition/_base.py +++ b/plugins/extract/recognition/_base.py @@ -4,7 +4,7 @@ All Recognition Plugins should inherit from this class. See the override methods for which methods are required. -The plugin will receive a :class:`~plugins.extract.pipeline.ExtractMedia` object. +The plugin will receive a :class:`~plugins.extract.extract_media.ExtractMedia` object. For each source frame, the plugin must pass a dict to finalize containing: @@ -24,11 +24,11 @@ from dataclasses import dataclass, field import numpy as np from tensorflow.python.framework import errors_impl as tf_errors # pylint:disable=no-name-in-module # noqa -from lib.align import AlignedFace, DetectedFace +from lib.align import AlignedFace, DetectedFace, LandmarkType from lib.image import read_image_meta from lib.utils import FaceswapError -from plugins.extract._base import BatchType, Extractor, ExtractorBatch -from plugins.extract.pipeline import ExtractMedia +from plugins.extract import ExtractMedia +from plugins.extract._base import BatchType, ExtractorBatch, Extractor if T.TYPE_CHECKING: from collections.abc import Generator @@ -75,6 +75,8 @@ class Identity(Extractor): # pylint:disable=abstract-method plugins.extract.mask._base : Masker parent class for extraction plugins. """ + _logged_lm_count_once = False + def __init__(self, git_model_id: int | None = None, model_filename: str | None = None, @@ -101,7 +103,7 @@ class Identity(Extractor): # pylint:disable=abstract-method Parameters ---------- - item: :class:`~plugins.extract.pipeline.ExtractMedia` + item: :class:`~plugins.extract.extract_media.ExtractMedia` The extract media to populate the detected face for """ detected_face = DetectedFace() @@ -113,14 +115,28 @@ class Identity(Extractor): # pylint:disable=abstract-method logger.debug("Obtained detected face: (filename: %s, detected_face: %s)", item.filename, item.detected_faces) + def _maybe_log_warning(self, face: AlignedFace) -> None: + """ Log a warning, once, if we do not have full facial landmarks + + Parameters + ---------- + face: :class:`~lib.align.aligned_face.AlignedFace` + The aligned face object to test the landmark type for + """ + if face.landmark_type != LandmarkType.LM_2D_4 or self._logged_lm_count_once: + return + logger.warning("Extracted faces do not contain facial landmark data. '%s' " + "identity data is likely to be sub-standard.", self.name) + self._logged_lm_count_once = True + def get_batch(self, queue: Queue) -> tuple[bool, RecogBatch]: """ Get items for inputting into the recognition from the queue in batches Items are returned from the ``queue`` in batches of :attr:`~plugins.extract._base.Extractor.batchsize` - Items are received as :class:`~plugins.extract.pipeline.ExtractMedia` objects and converted - to :class:`RecogBatch` for internal processing. + Items are received as :class:`~plugins.extract.extract_media.ExtractMedia` objects and + converted to :class:`RecogBatch` for internal processing. To ensure consistent batch sizes for masker the items are split into separate items for each :class:`~lib.align.DetectedFace` object. @@ -173,6 +189,8 @@ class Identity(Extractor): # pylint:disable=abstract-method dtype="float32", is_aligned=item.is_aligned) + self._maybe_log_warning(feed_face) + batch.detected_faces.append(face) batch.feed_faces.append(feed_face) batch.filename.append(item.filename) @@ -234,7 +252,7 @@ class Identity(Extractor): # pylint:disable=abstract-method Yields ------ - :class:`~plugins.extract.pipeline.ExtractMedia` + :class:`~plugins.extract.extract_media.ExtractMedia` The :attr:`DetectedFaces` list will be populated for this class with the bounding boxes, landmarks and masks for the detected faces found in the frame. """ diff --git a/scripts/convert.py b/scripts/convert.py index 57d9865..20cf4c6 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -23,7 +23,7 @@ from lib.image import read_image_meta_batch, ImagesLoader from lib.multithreading import MultiThread, total_cpus from lib.queue_manager import queue_manager from lib.utils import FaceswapError, get_folder, get_image_paths, handle_deprecated_cliopts -from plugins.extract.pipeline import Extractor, ExtractMedia +from plugins.extract import ExtractMedia, Extractor from plugins.plugin_loader import PluginLoader if T.TYPE_CHECKING: @@ -44,7 +44,7 @@ class ConvertItem: Parameters ---------- - input: :class:`~plugins.extract.pipeline.ExtractMedia` + input: :class:`~plugins.extract.extract_media.ExtractMedia` The ExtractMedia object holding the :attr:`filename`, :attr:`image` and attr:`list` of :class:`~lib.align.DetectedFace` objects loaded from disk feed_faces: list, Optional @@ -702,6 +702,7 @@ class DiskIO(): # Write out preview image for the GUI every 10 frames if writing to stream if write_preview and idx % 10 == 0 and not os.path.exists(preview_image): logger.debug("Writing GUI Preview image: '%s'", preview_image) + assert isinstance(image, np.ndarray) cv2.imwrite(preview_image, image) self._writer.write(filename, image) self._writer.close() @@ -1093,7 +1094,7 @@ class Predict(): logger.trace("Queued out batch. Batchsize: %s", len(batch)) # type:ignore -class OptionalActions(): +class OptionalActions(): # pylint:disable=too-few-public-methods """ Process specific optional actions for Convert. Currently only handles skip faces. This class should probably be (re)moved. diff --git a/scripts/extract.py b/scripts/extract.py index bd6cc41..da9edf1 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -17,7 +17,7 @@ from lib.align.alignments import PNGHeaderDict from lib.image import encode_image, generate_thumbnail, ImagesLoader, ImagesSaver, read_image_meta from lib.multithreading import MultiThread from lib.utils import get_folder, handle_deprecated_cliopts, IMAGE_EXTENSIONS, VIDEO_EXTENSIONS -from plugins.extract.pipeline import Extractor, ExtractMedia +from plugins.extract import ExtractMedia, Extractor from scripts.fsmedia import Alignments, PostProcess, finalize if T.TYPE_CHECKING: @@ -596,8 +596,8 @@ class PipelineLoader(): Parameters ---------- detected_faces: dict - Dictionary of :class:`plugins.extract.pipeline.ExtractMedia` with the filename as the - key for repopulating the image attribute. + Dictionary of :class:`~plugins.extract.extract_media.ExtractMedia` with the filename as + the key for repopulating the image attribute. """ logger.debug("Reload Images: Start. Detected Faces Count: %s", len(detected_faces)) load_queue = self._extractor.input_queue @@ -643,6 +643,7 @@ class _Extract(): self._alignments = Alignments(self._args, True, self._loader.is_video) self._extractor = extractor + self._extractor.import_data(self._args.input_dir) self._existing_count = 0 self._set_skip_list() @@ -753,7 +754,7 @@ class _Extract(): Parameters ---------- - extract_media: :class:`plugins.extract.pipeline.ExtractMedia` + extract_media: :class:`~plugins.extract.extract_media.ExtractMedia` Output from :class:`plugins.extract.pipeline.Extractor` size: int The size that the aligned face should be created at @@ -785,7 +786,7 @@ class _Extract(): ---------- saver: :class:`lib.images.ImagesSaver` or ``None`` The background saver for saving the image or ``None`` if faces are not to be saved - extract_media: :class:`~plugins.extract.pipeline.ExtractMedia` + extract_media: :class:`~plugins.extract.extract_media.ExtractMedia` The output from :class:`~plugins.extract.Pipeline.Extractor` """ logger.trace("Outputting faces for %s", extract_media.filename) # type: ignore diff --git a/scripts/fsmedia.py b/scripts/fsmedia.py index e519078..68f503a 100644 --- a/scripts/fsmedia.py +++ b/scripts/fsmedia.py @@ -25,7 +25,7 @@ if T.TYPE_CHECKING: from collections.abc import Generator from argparse import Namespace from lib.align import AlignedFace - from plugins.extract.pipeline import ExtractMedia + from plugins.extract import ExtractMedia logger = logging.getLogger(__name__) @@ -414,14 +414,15 @@ class PostProcess(): Parameters ---------- - extract_media: :class:`~plugins.extract.pipeline.ExtractMedia` - The :class:`~plugins.extract.pipeline.ExtractMedia` object to perform the + extract_media: :class:`~plugins.extract.extract_media.ExtractMedia` + The :class:`~plugins.extract.extract_media.ExtractMedia` object to perform the action on. Returns ------- - :class:`~plugins.extract.pipeline.ExtractMedia` - The original :class:`~plugins.extract.pipeline.ExtractMedia` with any actions applied + :class:`~plugins.extract.extract_media.ExtractMedia` + The original :class:`~plugins.extract.extract_media.ExtractMedia` with any actions + applied """ for action in self._actions: logger.debug("Performing postprocess action: '%s'", action.__class__.__name__) @@ -458,8 +459,8 @@ class PostProcessAction(): Parameters ---------- - extract_media: :class:`~plugins.extract.pipeline.ExtractMedia` - The :class:`~plugins.extract.pipeline.ExtractMedia` object to perform the + extract_media: :class:`~plugins.extract.extract_media.ExtractMedia` + The :class:`~plugins.extract.extract_media.ExtractMedia` object to perform the action on. """ raise NotImplementedError @@ -578,9 +579,9 @@ class DebugLandmarks(PostProcessAction): Parameters ---------- - extract_media: :class:`~plugins.extract.pipeline.ExtractMedia` - The :class:`~plugins.extract.pipeline.ExtractMedia` object that contains the faces to - draw the landmarks on to + extract_media: :class:`~plugins.extract.extract_media.ExtractMedia` + The :class:`~plugins.extract.extract_media.ExtractMedia` object that contains the faces + to draw the landmarks on to """ frame = os.path.splitext(os.path.basename(extract_media.filename))[0] for idx, face in enumerate(extract_media.detected_faces): diff --git a/tools/alignments/alignments.py b/tools/alignments/alignments.py index 8c9af32..531c695 100644 --- a/tools/alignments/alignments.py +++ b/tools/alignments/alignments.py @@ -10,7 +10,7 @@ from multiprocessing import Process from lib.utils import FaceswapError, handle_deprecated_cliopts, VIDEO_EXTENSIONS from .media import AlignmentData -from .jobs import Check, Sort, Spatial # noqa pylint:disable=unused-import +from .jobs import Check, Export, Sort, Spatial # noqa pylint:disable=unused-import from .jobs_faces import FromFaces, RemoveFaces, Rename # noqa pylint:disable=unused-import from .jobs_frames import Draw, Extract # noqa pylint:disable=unused-import @@ -34,7 +34,7 @@ class Alignments(): """ def __init__(self, arguments: Namespace) -> None: logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) - self._requires_alignments = ["sort", "spatial"] + self._requires_alignments = ["export", "sort", "spatial"] self._requires_faces = ["extract", "from-faces"] self._requires_frames = ["draw", "extract", diff --git a/tools/alignments/cli.py b/tools/alignments/cli.py index 2622110..84be726 100644 --- a/tools/alignments/cli.py +++ b/tools/alignments/cli.py @@ -52,8 +52,9 @@ class AlignmentsArgs(FaceSwapArgs): "opts": ("-j", "--job"), "action": Radio, "type": str, - "choices": ("draw", "extract", "from-faces", "missing-alignments", "missing-frames", - "multi-faces", "no-faces", "remove-faces", "rename", "sort", "spatial"), + "choices": ("draw", "extract", "export", "from-faces", "missing-alignments", + "missing-frames", "multi-faces", "no-faces", "remove-faces", "rename", + "sort", "spatial"), "group": _("processing"), "required": True, "help": _( @@ -61,6 +62,12 @@ class AlignmentsArgs(FaceSwapArgs): "alignments file (-a) to be passed in." "\nL|'draw': Draw landmarks on frames in the selected folder/video. A " "subfolder will be created within the frames folder to hold the output.{0}" + "\nL|'export': Export the contents of an alignments file to a json file. Can be " + "used for editing alignment information in external tools and then re-importing " + "by using Faceswap's Extract 'Import' plugins. Note: masks and identity vectors " + "will not be included in the exported file, so will be re-generated when the json " + "file is imported back into Faceswap. All data is exported with the origin (0, 0) " + "at the top left of the canvas." "\nL|'extract': Re-extract faces from the source frames/video based on " "alignment data. This is a lot quicker than re-detecting faces. Can pass in " "the '-een' (--extract-every-n) parameter to only extract every nth frame.{1}" diff --git a/tools/alignments/jobs.py b/tools/alignments/jobs.py index 2642eba..578ade1 100644 --- a/tools/alignments/jobs.py +++ b/tools/alignments/jobs.py @@ -13,19 +13,23 @@ from scipy import signal from sklearn import decomposition from tqdm import tqdm +from lib.logger import parse_class_init +from lib.serializer import get_serializer +from lib.utils import FaceswapError + from .media import Faces, Frames from .jobs_faces import FaceToFile if T.TYPE_CHECKING: from collections.abc import Generator from argparse import Namespace - from lib.align.alignments import PNGHeaderDict + from lib.align.alignments import AlignmentFileDict, PNGHeaderDict from .media import AlignmentData logger = logging.getLogger(__name__) -class Check(): +class Check: """ Frames and faces checking tasks. Parameters @@ -36,7 +40,7 @@ class Check(): The command line arguments that have called this job """ def __init__(self, alignments: AlignmentData, arguments: Namespace) -> None: - logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) + logger.debug(parse_class_init(locals())) self._alignments = alignments self._job = arguments.job self._type: T.Literal["faces", "frames"] | None = None @@ -371,7 +375,81 @@ class Check(): os.rename(src, dst) -class Sort(): +class Export: + """ Export alignments from a Faceswap .fsa file to a json formatted file. + + Parameters + ---------- + alignments: :class:`tools.lib_alignments.media.AlignmentData` + The alignments data loaded from an alignments file for this rename job + arguments: :class:`argparse.Namespace` + The :mod:`argparse` arguments as passed in from :mod:`tools.py`. Unused + """ + def __init__(self, + alignments: AlignmentData, + arguments: Namespace) -> None: # pylint:disable=unused-argument + logger.debug(parse_class_init(locals())) + self._alignments = alignments + self._serializer = get_serializer("json") + self._output_file = self._get_output_file() + logger.debug("Initialized %s", self.__class__.__name__) + + def _get_output_file(self) -> str: + """ Obtain the name of an output file. If a file of the request name exists, then append a + digit to the end until a unique filename is found + + Returns + ------- + str + Full path to an output json file + """ + in_file = self._alignments.file + base_filename = f"{os.path.splitext(in_file)[0]}_export" + out_file = f"{base_filename}.json" + idx = 1 + while True: + if not os.path.exists(out_file): + break + logger.debug("Output file exists: '%s'", out_file) + out_file = f"{base_filename}_{idx}.json" + idx += 1 + logger.debug("Setting output file to '%s'", out_file) + return out_file + + @classmethod + def _format_face(cls, face: AlignmentFileDict) -> dict[str, list[int] | list[list[float]]]: + """ Format the relevant keys from an alignment file's face into the correct format for + export/import + + Parameters + ---------- + face: :class:`~lib.align.alignments.AlignmentFileDict` + The alignment dictionary for a face to process + + Returns + ------- + dict[str, list[int] | list[list[float]]] + The face formatted for exporting to a json file + """ + lms = face["landmarks_xy"] + assert isinstance(lms, np.ndarray) + retval = {"detected": [int(round(face["x"], 0)), + int(round(face["y"], 0)), + int(round(face["x"] + face["w"], 0)), + int(round(face["y"] + face["h"], 0))], + "landmarks_2d": lms.tolist()} + return retval + + def process(self) -> None: + """ Parse the imported alignments file and output relevant information to a json file """ + logger.info("[EXPORTING ALIGNMENTS]") # Tidy up cli output + formatted = {key: [self._format_face(face) for face in val["faces"]] + for key, val in self._alignments.data.items()} + logger.info("Saving export alignments to '%s'...", self._output_file) + self._serializer.save(self._output_file, formatted) + + +class Sort: """ Sort alignments' index by the order they appear in an image in left to right order. Parameters @@ -379,10 +457,12 @@ class Sort(): alignments: :class:`tools.lib_alignments.media.AlignmentData` The alignments data loaded from an alignments file for this rename job arguments: :class:`argparse.Namespace` - The :mod:`argparse` arguments as passed in from :mod:`tools.py` + The :mod:`argparse` arguments as passed in from :mod:`tools.py`. Unused """ - def __init__(self, alignments: AlignmentData, arguments: Namespace) -> None: - logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) + def __init__(self, + alignments: AlignmentData, + arguments: Namespace) -> None: # pylint:disable=unused-argument + logger.debug(parse_class_init(locals())) self._alignments = alignments logger.debug("Initialized %s", self.__class__.__name__) @@ -418,7 +498,7 @@ class Sort(): return reindexed -class Spatial(): +class Spatial: """ Apply spatial temporal filtering to landmarks Parameters @@ -433,7 +513,7 @@ class Spatial(): https://www.kaggle.com/selfishgene/animating-and-smoothing-3d-facial-keypoints/notebook """ def __init__(self, alignments: AlignmentData, arguments: Namespace) -> None: - logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments) + logger.debug(parse_class_init(locals())) self.arguments = arguments self._alignments = alignments self._mappings: dict[int, str] = {} @@ -467,7 +547,7 @@ class Spatial(): Parameters ---------- shaped_im_coords: :class:`numpy.ndarray` - The 68 point landmarks + The facial landmarks Returns ------- @@ -530,7 +610,15 @@ class Spatial(): """ Compile all original and normalized alignments """ logger.debug("Normalize") count = sum(1 for val in self._alignments.data.values() if val["faces"]) - landmarks_all = np.zeros((68, 2, int(count))) + + sample_lm = next((val["faces"][0]["landmarks_xy"] + for val in self._alignments.data.values() if val["faces"]), 68) + assert isinstance(sample_lm, np.ndarray) + lm_count = sample_lm.shape[0] + if lm_count != 68: + raise FaceswapError("Spatial smoothing only supports 68 point facial landmarks") + + landmarks_all = np.zeros((lm_count, 2, int(count))) end = 0 for key in tqdm(sorted(self._alignments.data.keys()), desc="Compiling", leave=False): @@ -539,7 +627,7 @@ class Spatial(): continue # We should only be normalizing a single face, so just take # the first landmarks found - landmarks = np.array(val[0]["landmarks_xy"]).reshape((68, 2, 1)) + landmarks = np.array(val[0]["landmarks_xy"]).reshape((lm_count, 2, 1)) start = end end = start + landmarks.shape[2] # Store in one big array diff --git a/tools/alignments/jobs_frames.py b/tools/alignments/jobs_frames.py index 314f803..3c25b48 100644 --- a/tools/alignments/jobs_frames.py +++ b/tools/alignments/jobs_frames.py @@ -12,10 +12,10 @@ import cv2 import numpy as np from tqdm import tqdm -from lib.align import DetectedFace, _EXTRACT_RATIOS +from lib.align import DetectedFace, EXTRACT_RATIOS, LANDMARK_PARTS, LandmarkType from lib.align.alignments import _VERSION, PNGHeaderDict from lib.image import encode_image, generate_thumbnail, ImagesSaver -from plugins.extract.pipeline import Extractor, ExtractMedia +from plugins.extract import ExtractMedia, Extractor from .media import ExtractedFaces, Frames if T.TYPE_CHECKING: @@ -41,14 +41,6 @@ class Draw(): self._alignments = alignments self._frames = Frames(arguments.frames_dir) self._output_folder = self._set_output() - self._mesh_areas = {"mouth": (48, 68), - "right_eyebrow": (17, 22), - "left_eyebrow": (22, 27), - "right_eye": (36, 42), - "left_eye": (42, 48), - "nose": (27, 36), - "jaw": (0, 17), - "chin": (8, 11)} logger.debug("Initialized %s", self.__class__.__name__) def _set_output(self) -> str: @@ -121,12 +113,11 @@ class Draw(): image: :class:`numpy.ndarray` The frame that extract boxes are to be annotated on to landmarks: :class:`numpy.ndarray` - The 68 point landmarks that are to be annotated onto the frame + The facial landmarks that are to be annotated onto the frame """ # Mesh - for area, indices in self._mesh_areas.items(): - fill = area in ("right_eye", "left_eye", "mouth") - cv2.polylines(image, [landmarks[indices[0]:indices[1]]], fill, (255, 255, 0), 1) + for start, end, fill in LANDMARK_PARTS[LandmarkType.from_shape(landmarks.shape)].values(): + cv2.polylines(image, [landmarks[start:end]], fill, (255, 255, 0), 1) # Landmarks for (pos_x, pos_y) in landmarks: cv2.circle(image, (pos_x, pos_y), 1, (0, 255, 255), -1) @@ -462,9 +453,9 @@ class Extract(): continue old_mask = mask.mask.astype("float32") / 255.0 size = old_mask.shape[0] - new_size = int(size + (size * _EXTRACT_RATIOS["face"]) / 2) + new_size = int(size + (size * EXTRACT_RATIOS["face"]) / 2) - shift = np.rint(offset * (size - (size * _EXTRACT_RATIOS["face"]))).astype("int32") + shift = np.rint(offset * (size - (size * EXTRACT_RATIOS["face"]))).astype("int32") pos = np.array([(new_size // 2 - size // 2) - shift[1], (new_size // 2) + (size // 2) - shift[1], (new_size // 2 - size // 2) - shift[0], diff --git a/tools/manual/detected_faces.py b/tools/manual/detected_faces.py index 29d187f..b0285a8 100644 --- a/tools/manual/detected_faces.py +++ b/tools/manual/detected_faces.py @@ -684,7 +684,7 @@ class FaceUpdate(): width: int, pnt_y: int, height: int, - aligner: T.Literal["cv2-dnn", "FAN"] = "FAN") -> None: + aligner: manual.TypeManualExtractor = "FAN") -> None: """ Update the bounding box for the :class:`~lib.align.DetectedFace` object at the given frame and face indices, with the given dimensions and update the 68 point landmarks from the :class:`~tools.manual.manual.Aligner` for the updated bounding box. diff --git a/tools/manual/faceviewer/frame.py b/tools/manual/faceviewer/frame.py index f2f77ea..27737db 100644 --- a/tools/manual/faceviewer/frame.py +++ b/tools/manual/faceviewer/frame.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 """ The Faces Viewer Frame and Canvas for Faceswap's Manual Tool. """ +from __future__ import annotations import colorsys import gettext import logging import platform import tkinter as tk from tkinter import ttk +import typing as T from math import floor, ceil from threading import Thread, Event @@ -14,9 +16,15 @@ import numpy as np from lib.gui.custom_widgets import RightClickMenu, Tooltip from lib.gui.utils import get_config, get_images from lib.image import hex_to_rgb, rgb_to_hex +from lib.logger import parse_class_init from .viewport import Viewport +if T.TYPE_CHECKING: + from tools.manual.detected_faces import DetectedFaces + from tools.manual.frameviewer.frame import DisplayFrame + from tools.manual.manual import TkGlobals + logger = logging.getLogger(__name__) # LOCALES @@ -39,16 +47,18 @@ class FacesFrame(ttk.Frame): # pylint:disable=too-many-ancestors display_frame: :class:`~tools.manual.frameviewer.frame.DisplayFrame` The section of the Manual Tool that holds the frames viewer """ - def __init__(self, parent, tk_globals, detected_faces, display_frame): - logger.debug("Initializing %s: (parent: %s, tk_globals: %s, detected_faces: %s, " - "display_frame: %s)", self.__class__.__name__, parent, tk_globals, - detected_faces, display_frame) + def __init__(self, + parent: ttk.PanedWindow, + tk_globals: TkGlobals, + detected_faces: DetectedFaces, + display_frame: DisplayFrame) -> None: + logger.debug(parse_class_init(locals())) super().__init__(parent) self.pack(side=tk.TOP, fill=tk.BOTH, expand=True) self._actions_frame = FacesActionsFrame(self) self._faces_frame = ttk.Frame(self) - self._faces_frame.pack_propagate(0) + self._faces_frame.pack_propagate(False) self._faces_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) self._event = Event() self._canvas = FacesViewer(self._faces_frame, @@ -60,7 +70,7 @@ class FacesFrame(ttk.Frame): # pylint:disable=too-many-ancestors self._add_scrollbar() logger.debug("Initialized %s", self.__class__.__name__) - def _add_scrollbar(self): + def _add_scrollbar(self) -> None: """ Add a scrollbar to the faces frame """ logger.debug("Add Faces Viewer Scrollbar") scrollbar = ttk.Scrollbar(self._faces_frame, command=self._on_scroll) @@ -69,9 +79,8 @@ class FacesFrame(ttk.Frame): # pylint:disable=too-many-ancestors self.bind("", self._update_viewport) logger.debug("Added Faces Viewer Scrollbar") self.update_idletasks() # Update so scrollbar width is correct - return scrollbar.winfo_width() - def _on_scroll(self, *event): + def _on_scroll(self, *event: tk.Event) -> None: """ Callback on scrollbar scroll. Updates the canvas location and displays/hides thumbnail images. @@ -83,7 +92,7 @@ class FacesFrame(ttk.Frame): # pylint:disable=too-many-ancestors self._canvas.yview(*event) self._canvas.viewport.update() - def _update_viewport(self, event): # pylint:disable=unused-argument + def _update_viewport(self, event: tk.Event) -> None: # pylint:disable=unused-argument """ Update the faces viewport and scrollbar. Parameters @@ -94,7 +103,7 @@ class FacesFrame(ttk.Frame): # pylint:disable=too-many-ancestors self._canvas.viewport.update() self._canvas.configure(scrollregion=self._canvas.bbox("backdrop")) - def canvas_scroll(self, direction): + def canvas_scroll(self, direction: T.Literal["up", "down", "page-up", "page-down"]) -> None: """ Scroll the canvas on an up/down or page-up/page-down key press. Notes @@ -110,9 +119,11 @@ class FacesFrame(ttk.Frame): # pylint:disable=too-many-ancestors """ if self._event.is_set(): - logger.trace("Update already running. Aborting repeated keypress") + logger.trace("Update already running. " # type:ignore[attr-defined] + "Aborting repeated keypress") return - logger.trace("Running update on received key press: %s", direction) + logger.trace("Running update on received key press: %s", # type:ignore[attr-defined] + direction) amount = 1 if direction.endswith("down") else -1 units = "pages" if direction.startswith("page") else "units" @@ -121,7 +132,7 @@ class FacesFrame(ttk.Frame): # pylint:disable=too-many-ancestors args=(amount, units, self._event)) thread.start() - def set_annotation_display(self, key): + def set_annotation_display(self, key: str) -> None: """ Set the optional annotation overlay based on keyboard shortcut. Parameters @@ -140,33 +151,33 @@ class FacesActionsFrame(ttk.Frame): # pylint:disable=too-many-ancestors parent: :class:`FacesFrame` The Faces frame that this actions frame reside in """ - def __init__(self, parent): - logger.debug("Initializing %s: (parent: %s)", - self.__class__.__name__, parent) + def __init__(self, parent: FacesFrame) -> None: + logger.debug(parse_class_init(locals())) super().__init__(parent) self.pack(side=tk.LEFT, fill=tk.Y, padx=(2, 4), pady=2) - self._tk_vars = dict() + self._tk_vars: dict[T.Literal["mesh", "mask"], tk.BooleanVar] = {} self._configure_styles() self._buttons = self._add_buttons() logger.debug("Initialized %s", self.__class__.__name__) @property - def key_bindings(self): + def key_bindings(self) -> dict[str, T.Literal["mask", "mesh"]]: """ dict: The mapping of key presses to optional annotations to display. Keyboard shortcuts utilize the function keys. """ - return {"F{}".format(idx + 9): display for idx, display in enumerate(("mesh", "mask"))} + return {f"F{idx + 9}": display + for idx, display in enumerate(T.get_args(T.Literal["mesh", "mask"]))} @property - def _helptext(self): + def _helptext(self) -> dict[T.Literal["mask", "mesh"], str]: """ dict: `button key`: `button helptext`. The help text to display for each button. """ inverse_keybindings = {val: key for key, val in self.key_bindings.items()} - retval = dict(mesh=_("Display the landmarks mesh"), - mask=_("Display the mask")) + retval: dict[T.Literal["mask", "mesh"], str] = {"mesh": _('Display the landmarks mesh'), + "mask": _('Display the mask')} for item in retval: - retval[item] += " ({})".format(inverse_keybindings[item]) + retval[item] += f" ({inverse_keybindings[item]})" return retval - def _configure_styles(self): + def _configure_styles(self) -> None: """ Configure the background color for button frame and the button styles. """ style = ttk.Style() style.configure("display.TFrame", background='#d3d3d3') @@ -174,17 +185,17 @@ class FacesActionsFrame(ttk.Frame): # pylint:disable=too-many-ancestors style.configure("display_deselected.TButton", relief="flat") self.config(style="display.TFrame") - def _add_buttons(self): + def _add_buttons(self) -> dict[T.Literal["mesh", "mask"], ttk.Button]: """ Add the display buttons to the Faces window. Returns ------- - dict + dict[Literal["mesh", "mask"], tk.Button]] The display name and its associated button. """ frame = ttk.Frame(self) frame.pack(side=tk.TOP, fill=tk.Y) - buttons = dict() + buttons = {} for display in self.key_bindings.values(): var = tk.BooleanVar() var.set(False) @@ -193,7 +204,7 @@ class FacesActionsFrame(ttk.Frame): # pylint:disable=too-many-ancestors lookup = "landmarks" if display == "mesh" else display button = ttk.Button(frame, image=get_images().icons[lookup], - command=lambda t=display: self.on_click(t), + command=T.cast(T.Callable, lambda t=display: self.on_click(t)), style="display_deselected.TButton") button.state(["!pressed", "!focus"]) button.pack() @@ -201,13 +212,13 @@ class FacesActionsFrame(ttk.Frame): # pylint:disable=too-many-ancestors buttons[display] = button return buttons - def on_click(self, display): + def on_click(self, display: T.Literal["mesh", "mask"]) -> None: """ Click event for the optional annotation buttons. Loads and unloads the annotations from the faces viewer. Parameters ---------- - display: str + display: Literal["mesh", "mask"] The display name for the button that has called this event as exists in :attr:`_buttons` """ @@ -239,16 +250,19 @@ class FacesViewer(tk.Canvas): # pylint:disable=too-many-ancestors event: :class:`threading.Event` The threading event object for repeated key press protection """ - def __init__(self, parent, tk_globals, tk_action_vars, detected_faces, display_frame, event): - logger.debug("Initializing %s: (parent: %s, tk_globals: %s, tk_action_vars: %s, " - "detected_faces: %s, display_frame: %s, event: %s)", self.__class__.__name__, - parent, tk_globals, tk_action_vars, detected_faces, display_frame, event) + def __init__(self, parent: ttk.Frame, + tk_globals: TkGlobals, + tk_action_vars: dict[T.Literal["mesh", "mask"], tk.BooleanVar], + detected_faces: DetectedFaces, + display_frame: DisplayFrame, + event: Event) -> None: + logger.debug(parse_class_init(locals())) super().__init__(parent, bd=0, highlightthickness=0, bg=get_config().user_theme["group_panel"]["panel_background"]) self.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, anchor=tk.E) - self._sizes = dict(tiny=32, small=64, medium=96, large=128, extralarge=192) + self._sizes = {"tiny": 32, "small": 64, "medium": 96, "large": 128, "extralarge": 192} self._globals = tk_globals self._tk_optional_annotations = tk_action_vars @@ -256,8 +270,8 @@ class FacesViewer(tk.Canvas): # pylint:disable=too-many-ancestors self._display_frame = display_frame self._grid = Grid(self, detected_faces) self._view = Viewport(self, detected_faces.tk_edited) - self._annotation_colors = dict(mesh=self.get_muted_color("Mesh"), - box=self.control_colors["ExtractBox"]) + self._annotation_colors = {"mesh": self.get_muted_color("Mesh"), + "box": self.control_colors["ExtractBox"]} ContextMenu(self, detected_faces) self._bind_mouse_wheel_scrolling() @@ -265,7 +279,7 @@ class FacesViewer(tk.Canvas): # pylint:disable=too-many-ancestors logger.debug("Initialized %s", self.__class__.__name__) @property - def face_size(self): + def face_size(self) -> int: """ int: The currently selected thumbnail size in pixels """ scaling = get_config().scaling_factor size = self._sizes[self._globals.tk_faces_size.get().lower().replace(" ", "")] @@ -273,58 +287,65 @@ class FacesViewer(tk.Canvas): # pylint:disable=too-many-ancestors return int(round(scaled / 2) * 2) @property - def viewport(self): + def viewport(self) -> Viewport: """ :class:`~tools.manual.faceviewer.viewport.Viewport`: The viewport area of the faces viewer. """ return self._view @property - def grid(self): + def layout(self) -> Grid: """ :class:`Grid`: The grid for the current :class:`FacesViewer`. """ return self._grid @property - def optional_annotations(self): - """ dict: The values currently set for the selectable optional annotations. """ + def optional_annotations(self) -> dict[T.Literal["mesh", "mask"], bool]: + """ dict[Literal["mesh", "mask"], bool]: The values currently set for the + selectable optional annotations. """ return {opt: val.get() for opt, val in self._tk_optional_annotations.items()} @property - def selected_mask(self): + def selected_mask(self) -> str: """ str: The currently selected mask from the display frame control panel. """ return self._display_frame.tk_selected_mask.get().lower() @property - def control_colors(self): - """ :dict: The frame Editor name as key with the current user selected hex code as + def control_colors(self) -> dict[str, str]: + """dict[str, str]: The frame Editor name as key with the current user selected hex code as value. """ return ({key: val.get() for key, val in self._display_frame.tk_control_colors.items()}) # << CALLBACK FUNCTIONS >> # - def _set_tk_callbacks(self, detected_faces): + def _set_tk_callbacks(self, detected_faces: DetectedFaces): """ Set the tkinter variable call backs. + Parameters + ---------- + detected_faces: :class:`~tools.manual.detected_faces.DetectedFaces` + The Manual Tool's Detected Faces object + Redraw the grid on a face size change, a filter change or on add/remove faces. Updates the annotation colors when user amends a color drop down. Updates the mask type when the user changes the selected mask types Toggles the face viewer annotations on an optional annotation button press. """ for var in (self._globals.tk_faces_size, self._globals.tk_filter_mode): - var.trace("w", lambda *e, v=var: self.refresh_grid(v)) + var.trace_add("write", lambda *e, v=var: self.refresh_grid(v)) var = detected_faces.tk_face_count_changed - var.trace("w", lambda *e, v=var: self.refresh_grid(v, retain_position=True)) + var.trace_add("write", lambda *e, v=var: self.refresh_grid(v, retain_position=True)) - self._display_frame.tk_control_colors["Mesh"].trace( - "w", lambda *e: self._update_mesh_color()) - self._display_frame.tk_control_colors["ExtractBox"].trace( - "w", lambda *e: self._update_box_color()) - self._display_frame.tk_selected_mask.trace("w", lambda *e: self._update_mask_type()) + self._display_frame.tk_control_colors["Mesh"].trace_add( + "write", lambda *e: self._update_mesh_color()) + self._display_frame.tk_control_colors["ExtractBox"].trace_add( + "write", lambda *e: self._update_box_color()) + self._display_frame.tk_selected_mask.trace_add( + "write", lambda *e: self._update_mask_type()) for opt, var in self._tk_optional_annotations.items(): - var.trace("w", lambda *e, o=opt: self._toggle_annotations(o)) + var.trace_add("write", lambda *e, o=opt: self._toggle_annotations(o)) self.bind("", lambda *e: self._view.update()) - def refresh_grid(self, trigger_var, retain_position=False): + def refresh_grid(self, trigger_var: tk.BooleanVar, retain_position: bool = False) -> None: """ Recalculate the full grid and redraw. Used when the active filter pull down is used, a face has been added or removed, or the face thumbnail size has changed. @@ -351,15 +372,16 @@ class FacesViewer(tk.Canvas): # pylint:disable=too-many-ancestors if not size_change: trigger_var.set(False) - def _update_mask_type(self): + def _update_mask_type(self) -> None: """ Update the displayed mask in the :class:`FacesViewer` canvas when the user changes the mask type. """ + state: T.Literal["normal", "hidden"] state = "normal" if self.optional_annotations["mask"] else "hidden" logger.debug("Updating mask type: (mask_type: %s. state: %s)", self.selected_mask, state) self._view.toggle_mask(state, self.selected_mask) # << MOUSE HANDLING >> - def _bind_mouse_wheel_scrolling(self): + def _bind_mouse_wheel_scrolling(self) -> None: """ Bind mouse wheel to scroll the :class:`FacesViewer` canvas. """ if platform.system() == "Linux": self.bind("", self._scroll) @@ -367,7 +389,7 @@ class FacesViewer(tk.Canvas): # pylint:disable=too-many-ancestors else: self.bind("", self._scroll) - def _scroll(self, event): + def _scroll(self, event: tk.Event) -> None: """ Handle mouse wheel scrolling over the :class:`FacesViewer` canvas. Update is run in a thread to avoid repeated scroll actions stacking and locking up the GUI. @@ -378,12 +400,13 @@ class FacesViewer(tk.Canvas): # pylint:disable=too-many-ancestors The event fired by the mouse scrolling """ if self._event.is_set(): - logger.trace("Update already running. Aborting repeated mousewheel") + logger.trace("Update already running. " # type:ignore[attr-defined] + "Aborting repeated mousewheel") return if platform.system() == "Darwin": adjust = event.delta elif platform.system() == "Windows": - adjust = event.delta / 120 + adjust = int(event.delta / 120) elif event.num == 5: adjust = -1 else: @@ -392,14 +415,14 @@ class FacesViewer(tk.Canvas): # pylint:disable=too-many-ancestors thread = Thread(target=self.canvas_scroll, args=(-1 * adjust, "units", self._event)) thread.start() - def canvas_scroll(self, amount, units, event): + def canvas_scroll(self, amount: int, units: T.Literal["pages", "units"], event: Event) -> None: """ Scroll the canvas on an up/down or page-up/page-down key press. Parameters ---------- amount: int The number of units to scroll the canvas - units: ["page", "units"] + units: Literal["pages", "units"] The unit type to scroll by event: :class:`threading.Event` event to indicate to the calling process whether the scroll is still updating @@ -410,7 +433,7 @@ class FacesViewer(tk.Canvas): # pylint:disable=too-many-ancestors event.clear() # << OPTIONAL ANNOTATION METHODS >> # - def _update_mesh_color(self): + def _update_mesh_color(self) -> None: """ Update the mesh color when user updates the control panel. """ color = self.get_muted_color("Mesh") if self._annotation_colors["mesh"] == color: @@ -423,7 +446,7 @@ class FacesViewer(tk.Canvas): # pylint:disable=too-many-ancestors self.itemconfig("active_mesh_line", fill=highlight_color) self._annotation_colors["mesh"] = color - def _update_box_color(self): + def _update_box_color(self) -> None: """ Update the active box color when user updates the control panel. """ color = self.control_colors["ExtractBox"] @@ -432,13 +455,18 @@ class FacesViewer(tk.Canvas): # pylint:disable=too-many-ancestors self.itemconfig("active_highlighter", outline=color) self._annotation_colors["box"] = color - def get_muted_color(self, color_key): + def get_muted_color(self, color_key: str) -> str: """ Creates a muted version of the given annotation color for non-active faces. Parameters ---------- color_key: str The annotation key to obtain the color for from :attr:`control_colors` + + Returns + ------- + str + The hex color code of the muted color """ scale = 0.65 hls = np.array(colorsys.rgb_to_hls(*hex_to_rgb(self.control_colors[color_key]))) @@ -448,7 +476,7 @@ class FacesViewer(tk.Canvas): # pylint:disable=too-many-ancestors retval = rgb_to_hex(rgb) return retval - def _toggle_annotations(self, annotation): + def _toggle_annotations(self, annotation: T.Literal["mesh", "mask"]) -> None: """ Toggle optional annotations on or off after the user depresses an optional button. Parameters @@ -456,6 +484,7 @@ class FacesViewer(tk.Canvas): # pylint:disable=too-many-ancestors annotation: ["mesh", "mask"] The optional annotation to toggle on or off """ + state: T.Literal["hidden", "normal"] state = "normal" if self.optional_annotations[annotation] else "hidden" logger.debug("Toggle annotation: (annotation: %s, state: %s)", annotation, state) if annotation == "mesh": @@ -473,23 +502,22 @@ class Grid(): Parameters ---------- - canvas: :class:`tkinter.Canvas` + canvas: :class:`~FacesViewer` The :class:`~tools.manual.faceviewer.frame.FacesViewer` canvas detected_faces: :class:`~tools.manual.detected_faces.DetectedFaces` The :class:`~lib.align.DetectedFace` objects for this video """ - def __init__(self, canvas, detected_faces): - logger.debug("Initializing %s: (detected_faces: %s)", - self.__class__.__name__, detected_faces) + def __init__(self, canvas: FacesViewer, detected_faces: DetectedFaces): + logger.debug(parse_class_init(locals())) self._canvas = canvas self._detected_faces = detected_faces self._raw_indices = detected_faces.filter.raw_indices self._frames_list = detected_faces.filter.frames_list - self._is_valid = False - self._face_size = None - self._grid = None - self._display_faces = None + self._is_valid: bool = False + self._face_size: int = 0 + self._grid: np.ndarray | None = None + self._display_faces: np.ndarray | None = None self._canvas.update_idletasks() self._canvas.create_rectangle(0, 0, 0, 0, tags=["backdrop"]) @@ -497,64 +525,76 @@ class Grid(): logger.debug("Initialized %s", self.__class__.__name__) @property - def face_size(self): + def face_size(self) -> int: """ int: The pixel size of each thumbnail within the face viewer. """ return self._face_size @property - def is_valid(self): + def is_valid(self) -> bool: """ bool: ``True`` if the current filter means that the grid holds faces. ``False`` if there are no faces displayed in the grid. """ return self._is_valid @property - def columns_rows(self): + def columns_rows(self) -> tuple[int, int]: """ tuple: the (`columns`, `rows`) required to hold all display images. """ - retval = tuple(reversed(self._grid.shape[1:])) if self._is_valid else (0, 0) - return retval + if not self._is_valid: + return (0, 0) + assert self._grid is not None + retval = tuple(reversed(self._grid.shape[1:])) + return T.cast(tuple[int, int], retval) @property - def dimensions(self): + def dimensions(self) -> tuple[int, int]: """ tuple: The (`width`, `height`) required to hold all display images. """ if self._is_valid: + assert self._grid is not None retval = tuple(dim * self._face_size for dim in reversed(self._grid.shape[1:])) + assert len(retval) == 2 else: retval = (0, 0) - return retval + return T.cast(tuple[int, int], retval) @property - def _visible_row_indices(self): + def _visible_row_indices(self) -> tuple[int, int]: """tuple: A 1 dimensional array of the (`top_row_index`, `bottom_row_index`) of the grid currently in the viewable area. """ height = self.dimensions[1] visible = (max(0, floor(height * self._canvas.yview()[0]) - self._face_size), ceil(height * self._canvas.yview()[1])) - logger.trace("height: %s, yview: %s, face_size: %s, visible: %s", - height, self._canvas.yview(), self._face_size, visible) + logger.trace("height: %s, yview: %s, face_size: %s, " # type:ignore[attr-defined] + "visible: %s", height, self._canvas.yview(), self._face_size, visible) + assert self._grid is not None y_points = self._grid[3, :, 1] top = np.searchsorted(y_points, visible[0], side="left") bottom = np.searchsorted(y_points, visible[1], side="right") - return top, bottom + return int(top), int(bottom) @property - def visible_area(self): - """:class:`numpy.ndarray`: A numpy array of shape (`4`, `rows`, `columns`) corresponding + def visible_area(self) -> tuple[np.ndarray, np.ndarray]: + """tuple[:class:`numpy.ndarray`, :class:`numpy.ndarray`]: Tuple containing 2 arrays. + + 1st array contains an array of shape (`4`, `rows`, `columns`) corresponding to the viewable area of the display grid. 1st dimension contains frame indices, 2nd dimension face indices. The 3rd and 4th dimension contain the x and y position of the top left corner of the face respectively. + 2nd array contains :class:`~lib.align.DetectedFace` objects laid out in (rows, columns) + Any locations that are not populated by a face will have a frame and face index of -1 """ if not self._is_valid: - retval = None, None + retval = np.zeros((4, 0, 0)), np.zeros((0, 0)) else: + assert self._grid is not None + assert self._display_faces is not None top, bottom = self._visible_row_indices retval = self._grid[:, top:bottom, :], self._display_faces[top:bottom, :] - logger.trace([r if r is None else r.shape for r in retval]) + logger.trace([r if r is None else r.shape for r in retval]) # type:ignore[attr-defined] return retval - def y_coord_from_frame(self, frame_index): + def y_coord_from_frame(self, frame_index: int) -> int: """ Return the y coordinate for the first face that appears in the given frame. Parameters @@ -567,9 +607,10 @@ class Grid(): int The y coordinate of the first face for the given frame """ + assert self._grid is not None return min(self._grid[3][np.where(self._grid[0] == frame_index)]) - def frame_has_faces(self, frame_index): + def frame_has_faces(self, frame_index: int) -> bool | np.bool_: """ Check whether the given frame index contains any faces. Parameters @@ -582,9 +623,12 @@ class Grid(): bool ``True`` if there are faces in the given frame otherwise ``False`` """ - return self._is_valid and np.any(self._grid[0] == frame_index) + if not self._is_valid: + return False + assert self._grid is not None + return np.any(self._grid[0] == frame_index) - def update(self): + def update(self) -> None: """ Update the underlying grid. Called on initialization, on a filter change or on add/remove faces. Recalculates the @@ -597,25 +641,23 @@ class Grid(): self._get_grid() self._get_display_faces() self._canvas.coords("backdrop", 0, 0, *self.dimensions) - self._canvas.configure(scrollregion=(self._canvas.bbox("backdrop"))) + self._canvas.configure(scrollregion=self._canvas.bbox("backdrop")) self._canvas.yview_moveto(0.0) - def _get_grid(self): + def _get_grid(self) -> None: """ Get the grid information for faces currently displayed in the :class:`FacesViewer`. + and set to :attr:`_grid`. Creates a numpy array of shape (`4`, `rows`, `columns`) + corresponding to the display grid. 1st dimension contains frame indices, 2nd dimension face + indices. The 3rd and 4th dimension contain the x and y position of the top left corner of + the face respectively. - Returns - :class:`numpy.ndarray` - A numpy array of shape (`4`, `rows`, `columns`) corresponding to the display grid. - 1st dimension contains frame indices, 2nd dimension face indices. The 3rd and 4th - dimension contain the x and y position of the top left corner of the face respectively. - - Any locations that are not populated by a face will have a frame and face index of -1 - """ + Any locations that are not populated by a face will have a frame and face index of -1""" labels = self._get_labels() if not self._is_valid: logger.debug("Setting grid to None for no faces.") self._grid = None return + assert labels is not None x_coords = np.linspace(0, labels.shape[2] * self._face_size, num=labels.shape[2], @@ -629,12 +671,12 @@ class Grid(): self._grid = np.array((*labels, *np.meshgrid(x_coords, y_coords)), dtype="int") logger.debug(self._grid.shape) - def _get_labels(self): + def _get_labels(self) -> np.ndarray | None: """ Get the frame and face index for each grid position for the current filter. Returns ------- - :class:`numpy.ndarray` + :class:`numpy.ndarray` | None Array of dimensions (2, rows, columns) corresponding to the display grid, with frame index as the first dimension and face index within the frame as the 2nd dimension. @@ -657,17 +699,12 @@ class Grid(): return labels def _get_display_faces(self): - """ Get the detected faces for the current filter and arrange to grid. + """ Get the detected faces for the current filter, arrange to grid and set to + :attr:`_display_faces`. This is an array of dimensions (rows, columns) corresponding to the + display grid, containing the corresponding :class:`lib.align.DetectFace` object - Returns - ------- - :class:`numpy.ndarray` - Array of dimensions (rows, columns) corresponding to the display grid, containing the - corresponding :class:`lib.align.DetectFace` object - - Any remaining placeholders at the end of the grid which are not populated with a face - are replaced with ``None`` - """ + Any remaining placeholders at the end of the grid which are not populated with a face are + replaced with ``None``""" if not self._is_valid: logger.debug("Setting display_faces to None for no faces.") self._display_faces = None @@ -684,7 +721,7 @@ class Grid(): logger.debug("faces: (shape: %s, dtype: %s)", self._display_faces.shape, self._display_faces.dtype) - def transport_index_from_frame(self, frame_index): + def transport_index_from_frame(self, frame_index: int) -> int | None: """ Return the main frame's transport index for the given frame index based on the current filter criteria. @@ -695,11 +732,13 @@ class Grid(): Returns ------- - int - The index of the requested frame within the filtered frames view. + int | None + The index of the requested frame within the filtered frames view. None if no valid + frames """ retval = self._frames_list.index(frame_index) if frame_index in self._frames_list else None - logger.trace("frame_index: %s, transport_index: %s", frame_index, retval) + logger.trace("frame_index: %s, transport_index: %s", # type:ignore[attr-defined] + frame_index, retval) return retval @@ -737,17 +776,17 @@ class ContextMenu(): # pylint:disable=too-few-public-methods frame_idx, face_idx = self._canvas.viewport.face_from_point( self._canvas.canvasx(event.x), self._canvas.canvasy(event.y))[:2] if frame_idx == -1: - logger.trace("No valid item under mouse") + logger.trace("No valid item under mouse") # type:ignore[attr-defined] self._frame_index = self._face_index = None return self._frame_index = frame_idx self._face_index = face_idx - logger.trace("Popping right click menu") + logger.trace("Popping right click menu") # type:ignore[attr-defined] self._menu.popup(event) def _delete_face(self): """ Delete the selected face on a right click mouse delete action. """ - logger.trace("Right click delete received. frame_id: %s, face_id: %s", - self._frame_index, self._face_index) + logger.trace("Right click delete received. frame_id: %s, " # type:ignore[attr-defined] + "face_id: %s", self._frame_index, self._face_index) self._detected_faces.update.delete(self._frame_index, self._face_index) self._frame_index = self._face_index = None diff --git a/tools/manual/faceviewer/interact.py b/tools/manual/faceviewer/interact.py new file mode 100644 index 0000000..ae8edf3 --- /dev/null +++ b/tools/manual/faceviewer/interact.py @@ -0,0 +1,423 @@ +#!/usr/bin/env python3 +""" Handles the viewport area for mouse hover actions and the active frame """ +from __future__ import annotations +import logging +import tkinter as tk +import typing as T +from dataclasses import dataclass + +import numpy as np + +from lib.logger import parse_class_init + +if T.TYPE_CHECKING: + from lib.align import DetectedFace + from .viewport import Viewport + +logger = logging.getLogger(__name__) + + +class HoverBox(): + """ Handle the current mouse location when over the :class:`Viewport`. + + Highlights the face currently underneath the cursor and handles actions when clicking + on a face. + + Parameters + ---------- + viewport: :class:`Viewport` + The viewport object for the :class:`~tools.manual.faceviewer.frame.FacesViewer` canvas + """ + def __init__(self, viewport: Viewport) -> None: + logger.debug(parse_class_init(locals())) + self._viewport = viewport + self._canvas = viewport._canvas + self._grid = viewport._canvas.layout + self._globals = viewport._canvas._globals + self._navigation = viewport._canvas._display_frame.navigation + self._box = self._canvas.create_rectangle(0., # type:ignore[call-overload] + 0., + float(self._size), + float(self._size), + outline="#0000ff", + width=2, + state="hidden", + fill="#0000ff", + stipple="gray12", + tags="hover_box") + self._current_frame_index = None + self._current_face_index = None + self._canvas.bind("", lambda e: self._clear()) + self._canvas.bind("", self.on_hover) + self._canvas.bind("", lambda e: self._select_frame()) + logger.debug("Initialized: %s", self.__class__.__name__) + + @property + def _size(self) -> int: + """ int: the currently set viewport face size in pixels. """ + return self._viewport.face_size + + def on_hover(self, event: tk.Event | None) -> None: + """ Highlight the face and set the mouse cursor for the mouse's current location. + + Parameters + ---------- + event: :class:`tkinter.Event` or ``None`` + The tkinter mouse event. Provides the current location of the mouse cursor. If ``None`` + is passed as the event (for example when this function is being called outside of a + mouse event) then the location of the cursor will be calculated + """ + if event is None: + pnts = np.array((self._canvas.winfo_pointerx(), self._canvas.winfo_pointery())) + pnts -= np.array((self._canvas.winfo_rootx(), self._canvas.winfo_rooty())) + else: + pnts = np.array((event.x, event.y)) + + coords = (int(self._canvas.canvasx(pnts[0])), int(self._canvas.canvasy(pnts[1]))) + face = self._viewport.face_from_point(*coords) + frame_idx, face_idx = face[:2] + + if frame_idx == self._current_frame_index and face_idx == self._current_face_index: + return + + is_zoomed = self._globals.is_zoomed + if (-1 in face or (frame_idx == self._globals.frame_index + and (not is_zoomed or + (is_zoomed and face_idx == self._globals.tk_face_index.get())))): + self._clear() + self._canvas.config(cursor="") + self._current_frame_index = None + self._current_face_index = None + return + + logger.debug("Viewport hover: frame_idx: %s, face_idx: %s", frame_idx, face_idx) + + self._canvas.config(cursor="hand2") + self._highlight(face[2:]) + self._current_frame_index = frame_idx + self._current_face_index = face_idx + + def _clear(self) -> None: + """ Hide the hover box when the mouse is not over a face. """ + if self._canvas.itemcget(self._box, "state") != "hidden": + self._canvas.itemconfig(self._box, state="hidden") + + def _highlight(self, top_left: np.ndarray) -> None: + """ Display the hover box around the face that the mouse is currently over. + + Parameters + ---------- + top_left: :class:`np.ndarray` + The top left point of the highlight box location + """ + coords = (*top_left, *[x + self._size for x in top_left]) + self._canvas.coords(self._box, *coords) + self._canvas.itemconfig(self._box, state="normal") + self._canvas.tag_raise(self._box) + + def _select_frame(self) -> None: + """ Select the face and the subsequent frame (in the editor view) when a face is clicked + on in the :class:`Viewport`. """ + frame_id = self._current_frame_index + is_zoomed = self._globals.is_zoomed + logger.debug("Face clicked. Global frame index: %s, Current frame_id: %s, is_zoomed: %s", + self._globals.frame_index, frame_id, is_zoomed) + if frame_id is None or (frame_id == self._globals.frame_index and not is_zoomed): + return + face_idx = self._current_face_index if is_zoomed else 0 + self._globals.tk_face_index.set(face_idx) + transport_id = self._grid.transport_index_from_frame(frame_id) + logger.trace("frame_index: %s, transport_id: %s, face_idx: %s", + frame_id, transport_id, face_idx) + if transport_id is None: + return + self._navigation.stop_playback() + self._globals.tk_transport_index.set(transport_id) + self._viewport.move_active_to_top() + self.on_hover(None) + + +@dataclass +class Asset: + """ Holds all of the display assets identifiers for the active frame's face viewer objects + + Parameters + ---------- + images: list[int] + Indices for a frame's tk image ids displayed in the active frame + meshes: list[dict[Literal["polygon", "line"], list[int]]] + Indices for a frame's tk line/polygon object ids displayed in the active frame + faces: list[:class:`~lib.align.detected_faces.DetectedFace`] + DetectedFace objects that exist in the current frame + boxes: list[int] + Indices for a frame's bounding box object ids displayed in the active frame + """ + images: list[int] + """list[int]: Indices for a frame's tk image ids displayed in the active frame""" + meshes: list[dict[T.Literal["polygon", "line"], list[int]]] + """list[dict[Literal["polygon", "line"], list[int]]]: Indices for a frame's tk line/polygon + object ids displayed in the active frame""" + faces: list[DetectedFace] + """list[:class:`~lib.align.detected_faces.DetectedFace`]: DetectedFace objects that exist + in the current frame""" + boxes: list[int] + """list[int]: Indices for a frame's bounding box object ids displayed in the active + frame""" + + +class ActiveFrame(): + """ Handles the display of faces and annotations for the currently active frame. + + Parameters + ---------- + canvas: :class:`tkinter.Canvas` + The :class:`~tools.manual.faceviewer.frame.FacesViewer` canvas + tk_edited_variable: :class:`tkinter.BooleanVar` + The tkinter callback variable indicating that a face has been edited + """ + def __init__(self, viewport: Viewport, tk_edited_variable: tk.BooleanVar) -> None: + logger.debug(parse_class_init(locals())) + self._objects = viewport._objects + self._viewport = viewport + self._grid = viewport._grid + self._tk_faces = viewport._tk_faces + self._canvas = viewport._canvas + self._globals = viewport._canvas._globals + self._navigation = viewport._canvas._display_frame.navigation + self._last_execution: dict[T.Literal["frame_index", "size"], + int] = {"frame_index": -1, "size": viewport.face_size} + self._tk_vars: dict[T.Literal["selected_editor", "edited"], + tk.StringVar | tk.BooleanVar] = { + "selected_editor": self._canvas._display_frame.tk_selected_action, + "edited": tk_edited_variable} + self._assets: Asset = Asset([], [], [], []) + + self._globals.tk_update_active_viewport.trace_add("write", + lambda *e: self._reload_callback()) + tk_edited_variable.trace_add("write", lambda *e: self._update_on_edit()) + logger.debug("Initialized: %s", self.__class__.__name__) + + @property + def frame_index(self) -> int: + """ int: The frame index of the currently displayed frame. """ + return self._globals.frame_index + + @property + def current_frame(self) -> np.ndarray: + """ :class:`numpy.ndarray`: A BGR version of the frame currently being displayed. """ + return self._globals.current_frame["image"] + + @property + def _size(self) -> int: + """ int: The size of the thumbnails displayed in the viewport, in pixels. """ + return self._viewport.face_size + + @property + def _optional_annotations(self) -> dict[T.Literal["mesh", "mask"], bool]: + """ dict[Literal["mesh", "mask"], bool]: The currently selected optional + annotations """ + return self._canvas.optional_annotations + + def _reload_callback(self) -> None: + """ If a frame has changed, triggering the variable, then update the active frame. Return + having done nothing if the variable is resetting. """ + if self._globals.tk_update_active_viewport.get(): + self.reload_annotations() + + def reload_annotations(self) -> None: + """ Handles the reloading of annotations for the currently active faces. + + Highlights the faces within the viewport of those faces that exist in the currently + displaying frame. Applies annotations based on the optional annotations and current + editor selections. + """ + logger.trace("Reloading annotations") # type:ignore[attr-defined] + if self._assets.images: + self._clear_previous() + + self._set_active_objects() + self._check_active_in_view() + + if not self._assets.images: + logger.trace("No active faces. Returning") # type:ignore[attr-defined] + self._last_execution["frame_index"] = self.frame_index + return + + if self._last_execution["frame_index"] != self.frame_index: + self.move_to_top() + self._create_new_boxes() + + self._update_face() + self._canvas.tag_raise("active_highlighter") + self._globals.tk_update_active_viewport.set(False) + self._last_execution["frame_index"] = self.frame_index + + def _clear_previous(self) -> None: + """ Reverts the previously selected annotations to their default state. """ + logger.trace("Clearing previous active frame") # type:ignore[attr-defined] + self._canvas.itemconfig("active_highlighter", state="hidden") + + for key in T.get_args(T.Literal["polygon", "line"]): + tag = f"active_mesh_{key}" + self._canvas.itemconfig(tag, **self._viewport.mesh_kwargs[key], width=1) + self._canvas.dtag(tag) + + if self._viewport.selected_editor == "mask" and not self._optional_annotations["mask"]: + for name, tk_face in self._tk_faces.items(): + if name.startswith(f"{self._last_execution['frame_index']}_"): + tk_face.update_mask(None) + + def _set_active_objects(self) -> None: + """ Collect the objects that exist in the currently active frame from the main grid. """ + if self._grid.is_valid: + rows, cols = np.where(self._objects.visible_grid[0] == self.frame_index) + logger.trace("Setting active objects: (rows: %s, " # type:ignore[attr-defined] + "columns: %s)", rows, cols) + self._assets.images = self._objects.images[rows, cols].tolist() + self._assets.meshes = self._objects.meshes[rows, cols].tolist() + self._assets.faces = self._objects.visible_faces[rows, cols].tolist() + else: + logger.trace("No valid grid. Clearing active objects") # type:ignore[attr-defined] + self._assets.images = [] + self._assets.meshes = [] + self._assets.faces = [] + + def _check_active_in_view(self) -> None: + """ If the frame has changed, there are faces in the frame, but they don't appear in the + viewport, then bring the active faces to the top of the viewport. """ + if (not self._assets.images and + self._last_execution["frame_index"] != self.frame_index and + self._grid.frame_has_faces(self.frame_index)): + y_coord = self._grid.y_coord_from_frame(self.frame_index) + logger.trace("Active not in view. Moving to: %s", y_coord) # type:ignore[attr-defined] + self._canvas.yview_moveto(y_coord / self._canvas.bbox("backdrop")[3]) + self._viewport.update() + + def move_to_top(self) -> None: + """ Move the currently selected frame's faces to the top of the viewport if they are moving + off the bottom of the viewer. """ + height = self._canvas.bbox("backdrop")[3] + bot = int(self._canvas.coords(self._assets.images[-1])[1] + self._size) + + y_top, y_bot = (int(round(pnt * height)) for pnt in self._canvas.yview()) + + if y_top < bot < y_bot: # bottom face is still in fully visible area + logger.trace("Active faces in frame. Returning") # type:ignore[attr-defined] + return + + top = int(self._canvas.coords(self._assets.images[0])[1]) + if y_top == top: + logger.trace("Top face already on top row. Returning") # type:ignore[attr-defined] + return + + if self._canvas.winfo_height() > self._size: + logger.trace("Viewport taller than single face height. " # type:ignore[attr-defined] + "Moving Active faces to top: %s", top) + self._canvas.yview_moveto(top / height) + self._viewport.update() + elif self._canvas.winfo_height() <= self._size and y_top != top: + logger.trace("Viewport shorter than single face height. " # type:ignore[attr-defined] + "Moving Active faces to top: %s", top) + self._canvas.yview_moveto(top / height) + self._viewport.update() + + def _create_new_boxes(self) -> None: + """ The highlight boxes (border around selected faces) are the only additional annotations + that are required for the highlighter. If more faces are displayed in the current frame + than highlight boxes are available, then new boxes are created to accommodate the + additional faces. """ + new_boxes_count = max(0, len(self._assets.images) - len(self._assets.boxes)) + if new_boxes_count == 0: + return + logger.debug("new_boxes_count: %s", new_boxes_count) + for _ in range(new_boxes_count): + box = self._canvas.create_rectangle(0., # type:ignore[call-overload] + 0., + float(self._viewport.face_size), + float(self._viewport.face_size), + outline="#00FF00", + width=2, + state="hidden", + tags=["active_highlighter"]) + logger.trace("Created new highlight_box: %s", box) # type:ignore[attr-defined] + self._assets.boxes.append(box) + + def _update_on_edit(self) -> None: + """ Update the active faces on a frame edit. """ + if not self._tk_vars["edited"].get(): + return + self._set_active_objects() + self._update_face() + assert isinstance(self._tk_vars["edited"], tk.BooleanVar) + self._tk_vars["edited"].set(False) + + def _update_face(self) -> None: + """ Update the highlighted annotations for faces in the currently selected frame. """ + for face_idx, (image_id, mesh_ids, box_id, det_face), in enumerate( + zip(self._assets.images, + self._assets.meshes, + self._assets.boxes, + self._assets.faces)): + if det_face is None: + continue + top_left = self._canvas.coords(image_id) + coords = [*top_left, *[x + self._size for x in top_left]] + tk_face = self._viewport.get_tk_face(self.frame_index, face_idx, det_face) + self._canvas.itemconfig(image_id, image=tk_face.photo) + self._show_box(box_id, coords) + self._show_mesh(mesh_ids, face_idx, det_face, top_left) + self._last_execution["size"] = self._viewport.face_size + + def _show_box(self, item_id: int, coordinates: list[float]) -> None: + """ Display the highlight box around the given coordinates. + + Parameters + ---------- + item_id: int + The tkinter canvas object identifier for the highlight box + coordinates: list[float] + The (x, y, x1, y1) coordinates of the top left corner of the box + """ + self._canvas.coords(item_id, *coordinates) + self._canvas.itemconfig(item_id, state="normal") + + def _show_mesh(self, + mesh_ids: dict[T.Literal["polygon", "line"], list[int]], + face_index: int, + detected_face: DetectedFace, + top_left: list[float]) -> None: + """ Display the mesh annotation for the given face, at the given location. + + Parameters + ---------- + mesh_ids: dict[Literal["polygon", "line"], list[int]] + Dictionary containing the `polygon` and `line` tkinter canvas identifiers that make up + the mesh for the given face + face_index: int + The face index within the frame for the given face + detected_face: :class:`~lib.align.DetectedFace` + The detected face object that contains the landmarks for generating the mesh + top_left: list[float] + The (x, y) top left co-ordinates of the mesh's bounding box + """ + state = "normal" if (self._tk_vars["selected_editor"].get() != "Mask" or + self._optional_annotations["mesh"]) else "hidden" + kwargs: dict[T.Literal["polygon", "line"], dict[str, T.Any]] = { + "polygon": {"fill": "", "width": 2, "outline": self._canvas.control_colors["Mesh"]}, + "line": {"fill": self._canvas.control_colors["Mesh"], "width": 2}} + + assert isinstance(self._tk_vars["edited"], tk.BooleanVar) + edited = (self._tk_vars["edited"].get() and + self._tk_vars["selected_editor"].get() not in ("Mask", "View")) + landmarks = self._viewport.get_landmarks(self.frame_index, + face_index, + detected_face, + top_left, + edited) + for key, kwarg in kwargs.items(): + if key not in mesh_ids: + continue + for idx, mesh_id in enumerate(mesh_ids[key]): + self._canvas.coords(mesh_id, *landmarks[key][idx].flatten()) + self._canvas.itemconfig(mesh_id, state=state, **kwarg) + self._canvas.addtag_withtag(f"active_mesh_{key}", mesh_id) diff --git a/tools/manual/faceviewer/viewport.py b/tools/manual/faceviewer/viewport.py index 8b52ac2..3b06706 100644 --- a/tools/manual/faceviewer/viewport.py +++ b/tools/manual/faceviewer/viewport.py @@ -1,14 +1,22 @@ #!/usr/bin/env python3 """ Handles the visible area of the :class:`~tools.manual.faceviewer.frame.FacesViewer` canvas. """ - +from __future__ import annotations import logging import tkinter as tk +import typing as T import cv2 import numpy as np from PIL import Image, ImageTk -from lib.align import AlignedFace +from lib.align import AlignedFace, LANDMARK_PARTS, LandmarkType +from lib.logger import parse_class_init + +from .interact import ActiveFrame, HoverBox + +if T.TYPE_CHECKING: + from lib.align import CenteringType, DetectedFace + from .frame import FacesViewer logger = logging.getLogger(__name__) @@ -23,74 +31,64 @@ class Viewport(): tk_edited_variable: :class:`tkinter.BooleanVar` The variable that indicates that a face has been edited """ - def __init__(self, canvas, tk_edited_variable): - logger.debug("Initializing: %s: (canvas: %s, tk_edited_variable: %s)", - self.__class__.__name__, canvas, tk_edited_variable) + def __init__(self, canvas: FacesViewer, tk_edited_variable: tk.BooleanVar) -> None: + logger.debug(parse_class_init(locals())) self._canvas = canvas - self._grid = canvas.grid - self._centering = "face" + self._grid = canvas.layout + self._centering: CenteringType = "face" self._tk_selected_editor = canvas._display_frame.tk_selected_action - self._landmark_mapping = dict(mouth_inner=(60, 68), - mouth_outer=(48, 60), - right_eyebrow=(17, 22), - left_eyebrow=(22, 27), - right_eye=(36, 42), - left_eye=(42, 48), - nose=(27, 36), - jaw=(0, 17), - chin=(8, 11)) - self._landmarks = {} - self._tk_faces = {} + self._landmarks: dict[str, dict[T.Literal["polygon", "line"], list[np.ndarray]]] = {} + self._tk_faces: dict[str, TKFace] = {} self._objects = VisibleObjects(self) self._hoverbox = HoverBox(self) self._active_frame = ActiveFrame(self, tk_edited_variable) self._tk_selected_editor.trace( "w", lambda *e: self._active_frame.reload_annotations()) + logger.debug("Initialized %s", self.__class__.__name__) @property - def face_size(self): + def face_size(self) -> int: """ int: The pixel size of each thumbnail """ return self._grid.face_size @property - def mesh_kwargs(self): - """ dict: The color and state keyword arguments for the objects that make up a single - face's mesh annotation based on the current user selected options. Key is the object - type (`polygon` or `line`), value are the keyword arguments for that type. """ + def mesh_kwargs(self) -> dict[T.Literal["polygon", "line"], dict[str, T.Any]]: + """ dict[Literal["polygon", "line"], str | int]: Dynamic keyword arguments defining the + color and state for the objects that make up a single face's mesh annotation based on the + current user selected options. Values are the keyword arguments for that given type. """ state = "normal" if self._canvas.optional_annotations["mesh"] else "hidden" color = self._canvas.control_colors["Mesh"] - kwargs = dict(polygon=dict(fill="", outline=color, state=state), - line=dict(fill=color, state=state)) - return kwargs + return {"polygon": {"fill": "", "outline": color, "state": state}, + "line": {"fill": color, "state": state}} @property - def hover_box(self): + def hover_box(self) -> HoverBox: """ :class:`HoverBox`: The hover box for the viewport. """ return self._hoverbox @property - def selected_editor(self): + def selected_editor(self) -> str: """ str: The currently selected editor. """ return self._tk_selected_editor.get().lower() - def toggle_mesh(self, state): + def toggle_mesh(self, state: T.Literal["hidden", "normal"]) -> None: """ Toggles the mesh optional annotations on and off. Parameters ---------- - state: ["hidden", "normal"] + state: Literal["hidden", "normal"] The state to set the mesh annotations to """ logger.debug("Toggling mesh annotations to: %s", state) self._canvas.itemconfig("viewport_mesh", state=state) self.update() - def toggle_mask(self, state, mask_type): + def toggle_mask(self, state: T.Literal["hidden", "normal"], mask_type: str) -> None: """ Toggles the mask optional annotation on and off. Parameters ---------- - state: ["hidden", "normal"] + state: Literal["hidden", "normal"] Whether the mask should be displayed or hidden mask_type: str The type of mask to overlay onto the face @@ -108,7 +106,7 @@ class Viewport(): self.update() @classmethod - def _obtain_mask(cls, detected_face, mask_type): + def _obtain_mask(cls, detected_face: DetectedFace, mask_type: str) -> np.ndarray | None: """ Obtain the mask for the correct "face" centering that is used in the thumbnail display. Parameters @@ -133,12 +131,12 @@ class Viewport(): centering="face") return mask.mask.squeeze() - def reset(self): + def reset(self) -> None: """ Reset all the cached objects on a face size change. """ self._landmarks = {} self._tk_faces = {} - def update(self, refresh_annotations=False): + def update(self, refresh_annotations: bool = False) -> None: """ Update the viewport. Parameters @@ -153,7 +151,7 @@ class Viewport(): self._update_viewport(refresh_annotations) self._active_frame.reload_annotations() - def _update_viewport(self, refresh_annotations): + def _update_viewport(self, refresh_annotations: bool) -> None: """ Update the viewport Parameters @@ -174,12 +172,12 @@ class Viewport(): self._objects.meshes, self._objects.visible_faces): for (frame_idx, face_idx, pnt_x, pnt_y), image_id, mesh_ids, face in zip(*collection): - top_left = np.array((pnt_x, pnt_y)) if frame_idx == self._active_frame.frame_index and not refresh_annotations: - logger.trace("Skipping active frame: %s", frame_idx) + logger.trace("Skipping active frame: %s", # type:ignore[attr-defined] + frame_idx) continue if frame_idx == -1: - logger.trace("Blanking non-existant face") + logger.trace("Blanking non-existant face") # type:ignore[attr-defined] self._canvas.itemconfig(image_id, image="") for area in mesh_ids.values(): for mesh_id in area: @@ -192,20 +190,21 @@ class Viewport(): if (self._canvas.optional_annotations["mesh"] or frame_idx == self._active_frame.frame_index or refresh_annotations): - landmarks = self.get_landmarks(frame_idx, face_idx, face, top_left, + landmarks = self.get_landmarks(frame_idx, face_idx, face, [pnt_x, pnt_y], refresh=True) self._locate_mesh(mesh_ids, landmarks) - def _discard_tk_faces(self): + def _discard_tk_faces(self) -> None: """ Remove any :class:`TKFace` objects from the cache that are not currently displayed. """ keys = [f"{pnt_x}_{pnt_y}" for pnt_x, pnt_y in self._objects.visible_grid[:2].T.reshape(-1, 2)] for key in list(self._tk_faces): if key not in keys: del self._tk_faces[key] - logger.trace("keys: %s allocated_faces: %s", keys, len(self._tk_faces)) + logger.trace("keys: %s allocated_faces: %s", # type:ignore[attr-defined] + keys, len(self._tk_faces)) - def get_tk_face(self, frame_index, face_index, face): + def get_tk_face(self, frame_index: int, face_index: int, face: DetectedFace) -> TKFace: """ Obtain the :class:`TKFace` object for the given face from the cache. If the face does not exist in the cache, then it is generated and added prior to returning. @@ -227,26 +226,33 @@ class Viewport(): is_active = frame_index == self._active_frame.frame_index key = "_".join([str(frame_index), str(face_index)]) if key not in self._tk_faces or is_active: - logger.trace("creating new tk_face: (key: %s, is_active: %s)", key, is_active) + logger.trace("creating new tk_face: (key: %s, " # type:ignore[attr-defined] + "is_active: %s)", key, is_active) if is_active: image = AlignedFace(face.landmarks_xy, image=self._active_frame.current_frame, centering=self._centering, size=self.face_size).face else: + thumb = face.thumbnail + assert thumb is not None image = AlignedFace(face.landmarks_xy, - image=cv2.imdecode(face.thumbnail, cv2.IMREAD_UNCHANGED), + image=cv2.imdecode(thumb, cv2.IMREAD_UNCHANGED), centering=self._centering, size=self.face_size, is_aligned=True).face + assert image is not None tk_face = self._get_tk_face_object(face, image, is_active) self._tk_faces[key] = tk_face else: - logger.trace("tk_face exists: %s", key) + logger.trace("tk_face exists: %s", key) # type:ignore[attr-defined] tk_face = self._tk_faces[key] return tk_face - def _get_tk_face_object(self, face, image, is_active): + def _get_tk_face_object(self, + face: DetectedFace, + image: np.ndarray, + is_active: bool) -> TKFace: """ Obtain an existing unallocated, or a newly created :class:`TKFace` and populate it with face information from the requested frame and face index. @@ -272,10 +278,16 @@ class Viewport(): (is_active and self.selected_editor == "mask")) mask = self._obtain_mask(face, self._canvas.selected_mask) if get_mask else None tk_face = TKFace(image, size=self.face_size, mask=mask) - logger.trace("face: %s, tk_face: %s", face, tk_face) + logger.trace("face: %s, tk_face: %s", face, tk_face) # type:ignore[attr-defined] return tk_face - def get_landmarks(self, frame_index, face_index, face, top_left, refresh=False): + def get_landmarks(self, + frame_index: int, + face_index: int, + face: DetectedFace, + top_left: list[float], + refresh: bool = False + ) -> dict[T.Literal["polygon", "line"], list[np.ndarray]]: """ Obtain the landmark points for each mesh annotation. First tries to obtain the aligned landmarks from the cache. If the landmarks do not exist @@ -290,7 +302,7 @@ class Viewport(): The face index of the face within the requested frame face: :class:`lib.align.DetectedFace` The detected face object to obtain landmarks for - top_left: tuple + top_left: list[float] The top left (x, y) points of the face's bounding box within the viewport refresh: bool, optional Whether to force a reload of the face's aligned landmarks, even if they already exist @@ -309,10 +321,10 @@ class Viewport(): aligned = AlignedFace(face.landmarks_xy, centering=self._centering, size=self.face_size) - landmarks = dict(polygon=[], line=[]) - for area, val in self._landmark_mapping.items(): - points = aligned.landmarks[val[0]:val[1]] + top_left - shape = "polygon" if area.endswith("eye") or area.startswith("mouth") else "line" + landmarks = {"polygon": [], "line": []} + for start, end, fill in LANDMARK_PARTS[aligned.landmark_type].values(): + points = aligned.landmarks[start:end] + top_left + shape: T.Literal["polygon", "line"] = "polygon" if fill else "line" landmarks[shape].append(points) self._landmarks[key] = landmarks return landmarks @@ -328,10 +340,12 @@ class Viewport(): The mesh point groupings and whether each group should be a line or a polygon """ for key, area in landmarks.items(): + if key not in mesh_ids: + continue for coords, mesh_id in zip(area, mesh_ids[key]): self._canvas.coords(mesh_id, *coords.flatten()) - def face_from_point(self, point_x, point_y): + def face_from_point(self, point_x: int, point_y: int) -> np.ndarray: """ Given an (x, y) point on the :class:`Viewport`, obtain the face information at that location. @@ -360,15 +374,117 @@ class Viewport(): retval = np.array((-1, -1, -1, -1)) else: retval = self._objects.visible_grid[:, y_idx, x_idx] - logger.trace(retval) + logger.trace(retval) # type:ignore[attr-defined] return retval - def move_active_to_top(self): + def move_active_to_top(self) -> None: """ Check whether the active frame is going off the bottom of the viewport, if so: move it to the top of the viewport. """ self._active_frame.move_to_top() +class Recycler: + """ Tkinter can slow down when constantly creating new objects. + + This class delivers recycled objects, if stale objects are available, otherwise creates a new + object + + Parameters + ---------- + :class:`~tools.manual.faceviewe.frame.FacesViewer` + The canvas that holds the faces display + """ + def __init__(self, canvas: FacesViewer) -> None: + self._canvas = canvas + self._assets: dict[T.Literal["image", "line", "polygon"], + list[int]] = {"image": [], "line": [], "polygon": []} + self._mesh_methods: dict[T.Literal["line", "polygon"], + T.Callable] = {"line": canvas.create_line, + "polygon": canvas.create_polygon} + + def recycle_assets(self, asset_ids: list[int]) -> None: + """ Recycle assets that are no longer required + + Parameters + ---------- + asset_ids: list[int] + The IDs of the assets to be recycled + """ + logger.trace("Recycling %s objects", len(asset_ids)) # type:ignore[attr-defined] + for asset_id in asset_ids: + asset_type = self._canvas.type(asset_id) + assert asset_type in self._assets + coords = (0, 0, 0, 0) if asset_type == "line" else (0, 0) + self._canvas.coords(asset_id, *coords) + + if asset_type == "image": + self._canvas.itemconfig(asset_id, image="") + + self._assets[asset_type].append(asset_id) + logger.trace("Recycled objects: %s", self._assets) # type:ignore[attr-defined] + + def get_image(self, coordinates: tuple[float | int, float | int]) -> int: + """ Obtain a recycled or new image object ID + + Parameters + ---------- + coordinates: tuple[float | int, float | int] + The co-ordinates that the image should be displayed at + + Returns + ------- + int + The canvas object id for the created image + """ + if self._assets["image"]: + retval = self._assets["image"].pop() + self._canvas.coords(retval, *coordinates) + logger.trace("Recycled image: %s", retval) # type:ignore[attr-defined] + else: + retval = self._canvas.create_image(*coordinates, + anchor=tk.NW, + tags=["viewport", "viewport_image"]) + logger.trace("Created new image: %s", retval) # type:ignore[attr-defined] + return retval + + def get_mesh(self, face: DetectedFace) -> dict[T.Literal["polygon", "line"], list[int]]: + """ Get the mesh annotation for the landmarks. This is made up of a series of polygons + or lines, depending on which part of the face is being annotated. Creates a new series of + objects, or pulls existing objects from the recycled objects pool if they are available. + + Parameters + ---------- + face: :class:`~lib.align.detected_face.DetectedFace` + The detected face object to obrain the mesh for + + Returns + ------- + dict[Literal["polygon", "line"], list[int]] + The dictionary of line and polygon tkinter canvas object ids for the mesh annotation + """ + mesh_kwargs = self._canvas.viewport.mesh_kwargs + mesh_parts = LANDMARK_PARTS[LandmarkType.from_shape(face.landmarks_xy.shape)] + retval: dict[T.Literal["polygon", "line"], list[int]] = {} + for _, _, fill in mesh_parts.values(): + asset_type: T.Literal["polygon", "line"] = "polygon" if fill else "line" + kwargs = mesh_kwargs[asset_type] + if self._assets[asset_type]: + asset_id = self._assets[asset_type].pop() + self._canvas.itemconfig(asset_id, **kwargs) + logger.trace("Recycled mesh %s: %s", # type:ignore[attr-defined] + asset_type, asset_id) + else: + coords = (0, 0) if asset_type == "polygon" else (0, 0, 0, 0) + tags = ["viewport", "viewport_mesh", f"viewport_{asset_type}"] + asset_id = self._mesh_methods[asset_type](coords, width=1, tags=tags, **kwargs) + logger.trace("Created new mesh %s: %s", # type:ignore[attr-defined] + asset_type, asset_id) + + retval.setdefault(asset_type, []).append(asset_id) + logger.trace("Got mesh: %s", retval) # type:ignore[attr-defined] + return retval + + class VisibleObjects(): """ Holds the objects from the :class:`~tools.manual.faceviewer.frame.Grid` that appear in the viewable area of the :class:`Viewport`. @@ -378,20 +494,22 @@ class VisibleObjects(): viewport: :class:`Viewport` The viewport object for the :class:`~tools.manual.faceviewer.frame.FacesViewer` canvas """ - def __init__(self, viewport): + def __init__(self, viewport: Viewport) -> None: + logger.debug(parse_class_init(locals())) self._viewport = viewport self._canvas = viewport._canvas self._grid = viewport._grid self._size = viewport.face_size - self._visible_grid = None - self._visible_faces = None - self._images = [] - self._meshes = [] - self._recycled = dict(images=[], meshes=[]) + self._visible_grid = np.zeros((4, 0, 0)) + self._visible_faces = np.zeros((0, 0)) + self._recycler = Recycler(self._canvas) + self._images = np.zeros((0, 0), dtype=np.int64) + self._meshes = np.zeros((0, 0)) + logger.debug("Initialized: %s", self.__class__.__name__) @property - def visible_grid(self): + def visible_grid(self) -> np.ndarray: """ :class:`numpy.ndarray`: The currently visible section of the :class:`~tools.manual.faceviewer.frame.Grid` @@ -403,7 +521,7 @@ class VisibleObjects(): return self._visible_grid @property - def visible_faces(self): + def visible_faces(self) -> np.ndarray: """ :class:`numpy.ndarray`: The currently visible :class:`~lib.align.DetectedFace` objects. @@ -414,7 +532,7 @@ class VisibleObjects(): return self._visible_faces @property - def images(self): + def images(self) -> np.ndarray: """ :class:`numpy.ndarray`: The viewport's tkinter canvas image objects. A numpy array of shape (`rows`, `columns`) corresponding to the viewable area of the @@ -423,7 +541,7 @@ class VisibleObjects(): return self._images @property - def meshes(self): + def meshes(self) -> np.ndarray: """ :class:`numpy.ndarray`: The viewport's tkinter canvas mesh annotation objects. A numpy array of shape (`rows`, `columns`) corresponding to the viewable area of the @@ -433,28 +551,29 @@ class VisibleObjects(): return self._meshes @property - def _top_left(self): + def _top_left(self) -> np.ndarray: """ :class:`numpy.ndarray`: The canvas (`x`, `y`) position of the face currently in the viewable area's top left position. """ - if self._images is None or not np.any(self._images): - retval = [0, 0] + if not np.any(self._images): + retval = [0.0, 0.0] else: retval = self._canvas.coords(self._images[0][0]) return np.array(retval, dtype="int") - def update(self): + def update(self) -> None: """ Load and unload thumbnails in the visible area of the faces viewer. """ if self._canvas.optional_annotations["mesh"]: # Display any hidden end of row meshes self._canvas.itemconfig("viewport_mesh", state="normal") self._visible_grid, self._visible_faces = self._grid.visible_area - if (isinstance(self._images, np.ndarray) and isinstance(self._visible_grid, np.ndarray) + if (np.any(self._images) and np.any(self._visible_grid) and self._visible_grid.shape[1:] != self._images.shape): self._reset_viewport() required_rows = self._visible_grid.shape[1] if self._grid.is_valid else 0 existing_rows = len(self._images) - logger.trace("existing_rows: %s. required_rows: %s", existing_rows, required_rows) + logger.trace("existing_rows: %s. required_rows: %s", # type:ignore[attr-defined] + existing_rows, required_rows) if existing_rows > required_rows: self._remove_rows(existing_rows, required_rows) @@ -463,43 +582,20 @@ class VisibleObjects(): self._shift() - def _reset_viewport(self): + def _reset_viewport(self) -> None: """ Reset all objects in the viewport on a column count change. Reset the viewport size to the newly specified face size. """ logger.debug("Resetting Viewport") self._size = self._viewport.face_size images = self._images.flatten().tolist() - meshes = self._meshes.flatten().tolist() - self._recycle_objects(images, meshes) - self._images = [] - self._meshes = [] + meshes = [parts for mesh in [mesh.values() for mesh in self._meshes.flatten()] + for parts in mesh] + mesh_ids = [asset for mesh in meshes for asset in mesh] + self._recycler.recycle_assets(images + mesh_ids) + self._images = np.zeros((0, 0), np.int64) + self._meshes = np.zeros((0, 0)) - def _recycle_objects(self, images, meshes): - """ Reset the visible property and position of the given objects and add to the recycle - bin. - - Parameters - --------- - images: list - List of image_ids to be recycled - meshes: list - List of dictionaries containing the mesh annotation ids to be recycled - """ - logger.debug("Recycling objects: (images: %s, meshes: %s)", len(images), len(meshes)) - for image_id in images: - self._canvas.itemconfig(image_id, image="") - self._canvas.coords(image_id, 0, 0) - for mesh in meshes: - for key, mesh_ids in mesh.items(): - coords = (0, 0, 0, 0) if key == "line" else (0, 0) - for mesh_id in mesh_ids: - self._canvas.coords(mesh_id, *coords) - - self._recycled["images"].extend(images) - self._recycled["meshes"].extend(meshes) - logger.trace("Recycled objects: %s", self._recycled) - - def _remove_rows(self, existing_rows, required_rows): + def _remove_rows(self, existing_rows: int, required_rows: int) -> None: """ Remove and recycle rows from the viewport that are not in the view area. Parameters @@ -511,14 +607,19 @@ class VisibleObjects(): """ logger.debug("Removing rows from viewport: (existing_rows: %s, required_rows: %s)", existing_rows, required_rows) - self._recycle_objects(self._images[required_rows: existing_rows].flatten().tolist(), - self._meshes[required_rows: existing_rows].flatten().tolist()) + images = self._images[required_rows: existing_rows].flatten().tolist() + meshes = [parts + for mesh in [mesh.values() + for mesh in self._meshes[required_rows: existing_rows].flatten()] + for parts in mesh] + mesh_ids = [asset for mesh in meshes for asset in mesh] + self._recycler.recycle_assets(images + mesh_ids) self._images = self._images[:required_rows] self._meshes = self._meshes[:required_rows] - logger.trace("self._images: %s, self._meshes: %s", + logger.trace("self._images: %s, self._meshes: %s", # type:ignore[attr-defined] self._images.shape, self._meshes.shape) - def _add_rows(self, existing_rows, required_rows): + def _add_rows(self, existing_rows: int, required_rows: int) -> None: """ Add rows to the viewport. Parameters @@ -531,92 +632,41 @@ class VisibleObjects(): logger.debug("Adding rows to viewport: (existing_rows: %s, required_rows: %s)", existing_rows, required_rows) columns = self._grid.columns_rows[0] - if not isinstance(self._images, np.ndarray): - base_coords = [(col * self._size, 0) for col in range(columns)] + + base_coords: list[list[float | int]] + + if not np.any(self._images): + base_coords = [[col * self._size, 0] for col in range(columns)] else: base_coords = [self._canvas.coords(item_id) for item_id in self._images[0]] - logger.trace("existing rows: %s, required_rows: %s, base_coords: %s", - existing_rows, required_rows, base_coords) + logger.trace("existing rows: %s, required_rows: %s, " # type:ignore[attr-defined] + "base_coords: %s", existing_rows, required_rows, base_coords) images = [] meshes = [] for row in range(existing_rows, required_rows): y_coord = base_coords[0][1] + (row * self._size) - images.append(np.array([self._get_image((coords[0], y_coord)) - for coords in base_coords])) - meshes.append(np.array([self._get_mesh() for _ in range(columns)])) - images = np.array(images) - meshes = np.array(meshes) + images.append([self._recycler.get_image((coords[0], y_coord)) + for coords in base_coords]) + meshes.append([self._recycler.get_mesh(face) for face in self._visible_faces[row]]) - if not isinstance(self._images, np.ndarray): + a_images = np.array(images) + a_meshes = np.array(meshes) + + if not np.any(self._images): logger.debug("Adding initial viewport objects: (image shapes: %s, mesh shapes: %s)", - images.shape, meshes.shape) - self._images = images - self._meshes = meshes + a_images.shape, a_meshes.shape) + self._images = a_images + self._meshes = a_meshes else: logger.debug("Adding new viewport objects: (image shapes: %s, mesh shapes: %s)", - images.shape, meshes.shape) - self._images = np.concatenate((self._images, images)) - self._meshes = np.concatenate((self._meshes, meshes)) - logger.trace("self._images: %s, self._meshes: %s", self._images.shape, self._meshes.shape) + a_images.shape, a_meshes.shape) + self._images = np.concatenate((self._images, a_images)) + self._meshes = np.concatenate((self._meshes, a_meshes)) - def _get_image(self, coordinates): - """ Create or recycle a tkinter canvas image object with the given coordinates. + logger.trace("self._images: %s, self._meshes: %s", # type:ignore[attr-defined] + self._images.shape, self._meshes.shape) - Parameters - ---------- - coordinates: tuple - The (`x`, `y`) coordinates for the top left corner of the image - - Returns - ------- - int - The canvas object id for the created image - """ - if self._recycled["images"]: - image_id = self._recycled["images"].pop() - self._canvas.coords(image_id, *coordinates) - logger.trace("Recycled image: %s", image_id) - else: - image_id = self._canvas.create_image(*coordinates, - anchor=tk.NW, - tags=["viewport", "viewport_image"]) - logger.trace("Created new image: %s", image_id) - return image_id - - def _get_mesh(self): - """ Get the mesh annotation for the landmarks. This is made up of a series of polygons - or lines, depending on which part of the face is being annotated. Creates a new series of - objects, or pulls existing objects from the recycled objects pool if they are available. - - Returns - ------- - dict - The dictionary of line and polygon tkinter canvas object ids for the mesh annotation - """ - kwargs = self._viewport.mesh_kwargs - logger.trace("self.mesh_kwargs: %s", kwargs) - if self._recycled["meshes"]: - mesh = self._recycled["meshes"].pop() - for key, mesh_ids in mesh.items(): - for mesh_id in mesh_ids: - self._canvas.itemconfig(mesh_id, **kwargs[key]) - logger.trace("Recycled mesh: %s", mesh) - else: - tags = ["viewport", "viewport_mesh"] - mesh = dict(polygon=[self._canvas.create_polygon(0, 0, - width=1, - tags=tags + ["viewport_polygon"], - **kwargs["polygon"]) - for _ in range(4)], - line=[self._canvas.create_line(0, 0, 0, 0, - width=1, - tags=tags + ["viewport_line"], - **kwargs["line"]) - for _ in range(5)]) - logger.trace("Created new mesh: %s", mesh) - return mesh - - def _shift(self): + def _shift(self) -> bool: """ Shift the viewport in the y direction if required Returns @@ -625,378 +675,18 @@ class VisibleObjects(): ``True`` if the viewport was shifted otherwise ``False`` """ current_y = self._top_left[1] - required_y = self._visible_grid[3, 0, 0] if self._grid.is_valid else 0 - logger.trace("current_y: %s, required_y: %s", current_y, required_y) + required_y = self.visible_grid[3, 0, 0] if self._grid.is_valid else 0 + logger.trace("current_y: %s, required_y: %s", # type:ignore[attr-defined] + current_y, required_y) if current_y == required_y: - logger.trace("No move required") + logger.trace("No move required") # type:ignore[attr-defined] return False shift_amount = required_y - current_y - logger.trace("Shifting viewport: %s", shift_amount) + logger.trace("Shifting viewport: %s", shift_amount) # type:ignore[attr-defined] self._canvas.move("viewport", 0, shift_amount) return True -class HoverBox(): - """ Handle the current mouse location when over the :class:`Viewport`. - - Highlights the face currently underneath the cursor and handles actions when clicking - on a face. - - Parameters - ---------- - viewport: :class:`Viewport` - The viewport object for the :class:`~tools.manual.faceviewer.frame.FacesViewer` canvas - """ - def __init__(self, viewport): - logger.debug("Initializing: %s (viewport: %s)", self.__class__.__name__, viewport) - self._viewport = viewport - self._canvas = viewport._canvas - self._grid = viewport._canvas.grid - self._globals = viewport._canvas._globals - self._navigation = viewport._canvas._display_frame.navigation - self._box = self._canvas.create_rectangle(0, 0, self._size, self._size, - outline="#0000ff", - width=2, - state="hidden", - fill="#0000ff", - stipple="gray12", - tags="hover_box") - self._current_frame_index = None - self._current_face_index = None - self._canvas.bind("", lambda e: self._clear()) - self._canvas.bind("", self.on_hover) - self._canvas.bind("", lambda e: self._select_frame()) - logger.debug("Initialized: %s", self.__class__.__name__) - - @property - def _size(self): - """ int: the currently set viewport face size in pixels. """ - return self._viewport.face_size - - def on_hover(self, event): - """ Highlight the face and set the mouse cursor for the mouse's current location. - - Parameters - ---------- - event: :class:`tkinter.Event` or ``None`` - The tkinter mouse event. Provides the current location of the mouse cursor. If ``None`` - is passed as the event (for example when this function is being called outside of a - mouse event) then the location of the cursor will be calculated - """ - if event is None: - pnts = np.array((self._canvas.winfo_pointerx(), self._canvas.winfo_pointery())) - pnts -= np.array((self._canvas.winfo_rootx(), self._canvas.winfo_rooty())) - else: - pnts = (event.x, event.y) - - coords = (int(self._canvas.canvasx(pnts[0])), int(self._canvas.canvasy(pnts[1]))) - face = self._viewport.face_from_point(*coords) - frame_idx, face_idx = face[:2] - - if frame_idx == self._current_frame_index and face_idx == self._current_face_index: - return - - is_zoomed = self._globals.is_zoomed - if (-1 in face or (frame_idx == self._globals.frame_index - and (not is_zoomed or - (is_zoomed and face_idx == self._globals.tk_face_index.get())))): - self._clear() - self._canvas.config(cursor="") - self._current_frame_index = None - self._current_face_index = None - return - - logger.debug("Viewport hover: frame_idx: %s, face_idx: %s", frame_idx, face_idx) - - self._canvas.config(cursor="hand2") - self._highlight(face[2:]) - self._current_frame_index = frame_idx - self._current_face_index = face_idx - - def _clear(self): - """ Hide the hover box when the mouse is not over a face. """ - if self._canvas.itemcget(self._box, "state") != "hidden": - self._canvas.itemconfig(self._box, state="hidden") - - def _highlight(self, top_left): - """ Display the hover box around the face that the mouse is currently over. - - Parameters - ---------- - top_left: tuple - The top left point of the highlight box location - """ - coords = (*top_left, *top_left + self._size) - self._canvas.coords(self._box, *coords) - self._canvas.itemconfig(self._box, state="normal") - self._canvas.tag_raise(self._box) - - def _select_frame(self): - """ Select the face and the subsequent frame (in the editor view) when a face is clicked - on in the :class:`Viewport`. - """ - frame_id = self._current_frame_index - is_zoomed = self._globals.is_zoomed - logger.debug("Face clicked. Global frame index: %s, Current frame_id: %s, is_zoomed: %s", - self._globals.frame_index, frame_id, is_zoomed) - if frame_id is None or (frame_id == self._globals.frame_index and not is_zoomed): - return - face_idx = self._current_face_index if is_zoomed else 0 - self._globals.tk_face_index.set(face_idx) - transport_id = self._grid.transport_index_from_frame(frame_id) - logger.trace("frame_index: %s, transport_id: %s, face_idx: %s", - frame_id, transport_id, face_idx) - if transport_id is None: - return - self._navigation.stop_playback() - self._globals.tk_transport_index.set(transport_id) - self._viewport.move_active_to_top() - self.on_hover(None) - - -class ActiveFrame(): - """ Handles the display of faces and annotations for the currently active frame. - - Parameters - ---------- - canvas: :class:`tkinter.Canvas` - The :class:`~tools.manual.faceviewer.frame.FacesViewer` canvas - tk_edited_variable: :class:`tkinter.BooleanVar` - The tkinter callback variable indicating that a face has been edited - """ - def __init__(self, viewport, tk_edited_variable): - logger.debug("Initializing: %s (viewport: %s, tk_edited_variable: %s)", - self.__class__.__name__, viewport, tk_edited_variable) - self._objects = viewport._objects - self._viewport = viewport - self._grid = viewport._grid - self._tk_faces = viewport._tk_faces - self._canvas = viewport._canvas - self._globals = viewport._canvas._globals - self._navigation = viewport._canvas._display_frame.navigation - self._last_execution = dict(frame_index=-1, size=viewport.face_size) - self._tk_vars = dict(selected_editor=self._canvas._display_frame.tk_selected_action, - edited=tk_edited_variable) - self._assets = dict(images=[], meshes=[], faces=[], boxes=[]) - - self._globals.tk_update_active_viewport.trace("w", lambda *e: self._reload_callback()) - tk_edited_variable.trace("w", lambda *e: self._update_on_edit()) - logger.debug("Initialized: %s", self.__class__.__name__) - - @property - def frame_index(self): - """ int: The frame index of the currently displayed frame. """ - return self._globals.frame_index - - @property - def current_frame(self): - """ :class:`numpy.ndarray`: A BGR version of the frame currently being displayed. """ - return self._globals.current_frame["image"] - - @property - def _size(self): - """ int: The size of the thumbnails displayed in the viewport, in pixels. """ - return self._viewport.face_size - - @property - def _optional_annotations(self): - """ dict: The currently selected optional annotations """ - return self._canvas.optional_annotations - - def _reload_callback(self): - """ If a frame has changed, triggering the variable, then update the active frame. Return - having done nothing if the variable is resetting. """ - if self._globals.tk_update_active_viewport.get(): - self.reload_annotations() - - def reload_annotations(self): - """ Handles the reloading of annotations for the currently active faces. - - Highlights the faces within the viewport of those faces that exist in the currently - displaying frame. Applies annotations based on the optional annotations and current - editor selections. - """ - logger.trace("Reloading annotations") - if np.any(self._assets["images"]): - self._clear_previous() - - self._set_active_objects() - self._check_active_in_view() - - if not np.any(self._assets["images"]): - logger.trace("No active faces. Returning") - self._last_execution["frame_index"] = self.frame_index - return - - if self._last_execution["frame_index"] != self.frame_index: - self.move_to_top() - self._create_new_boxes() - - self._update_face() - self._canvas.tag_raise("active_highlighter") - self._globals.tk_update_active_viewport.set(False) - self._last_execution["frame_index"] = self.frame_index - - def _clear_previous(self): - """ Reverts the previously selected annotations to their default state. """ - logger.trace("Clearing previous active frame") - self._canvas.itemconfig("active_highlighter", state="hidden") - - for key in ("polygon", "line"): - tag = f"active_mesh_{key}" - self._canvas.itemconfig(tag, **self._viewport.mesh_kwargs[key], width=1) - self._canvas.dtag(tag) - - if self._viewport.selected_editor == "mask" and not self._optional_annotations["mask"]: - for key, tk_face in self._tk_faces.items(): - if key.startswith(f"{self._last_execution['frame_index']}_"): - tk_face.update_mask(None) - - def _set_active_objects(self): - """ Collect the objects that exist in the currently active frame from the main grid. """ - if self._grid.is_valid: - rows, cols = np.where(self._objects.visible_grid[0] == self.frame_index) - logger.trace("Setting active objects: (rows: %s, columns: %s)", rows, cols) - self._assets["images"] = self._objects.images[rows, cols] - self._assets["meshes"] = self._objects.meshes[rows, cols] - self._assets["faces"] = self._objects.visible_faces[rows, cols] - else: - logger.trace("No valid grid. Clearing active objects") - self._assets["images"] = [] - self._assets["meshes"] = [] - self._assets["faces"] = [] - - def _check_active_in_view(self): - """ If the frame has changed, there are faces in the frame, but they don't appear in the - viewport, then bring the active faces to the top of the viewport. """ - if (not np.any(self._assets["images"]) and - self._last_execution["frame_index"] != self.frame_index and - self._grid.frame_has_faces(self.frame_index)): - y_coord = self._grid.y_coord_from_frame(self.frame_index) - logger.trace("Active not in view. Moving to: %s", y_coord) - self._canvas.yview_moveto(y_coord / self._canvas.bbox("backdrop")[3]) - self._viewport.update() - - def move_to_top(self): - """ Move the currently selected frame's faces to the top of the viewport if they are moving - off the bottom of the viewer. """ - height = self._canvas.bbox("backdrop")[3] - bot = int(self._canvas.coords(self._assets["images"][-1])[1] + self._size) - - y_top, y_bot = (int(round(pnt * height)) for pnt in self._canvas.yview()) - - if y_top < bot < y_bot: # bottom face is still in fully visible area - logger.trace("Active faces in frame. Returning") - return - - top = int(self._canvas.coords(self._assets["images"][0])[1]) - if y_top == top: - logger.trace("Top face already on top row. Returning") - return - - if self._canvas.winfo_height() > self._size: - logger.trace("Viewport taller than single face height. Moving Active faces to top: %s", - top) - self._canvas.yview_moveto(top / height) - self._viewport.update() - elif self._canvas.winfo_height() <= self._size and y_top != top: - logger.trace("Viewport shorter than single face height. Moving Active faces to " - "top: %s", top) - self._canvas.yview_moveto(top / height) - self._viewport.update() - - def _create_new_boxes(self): - """ The highlight boxes (border around selected faces) are the only additional annotations - that are required for the highlighter. If more faces are displayed in the current frame - than highlight boxes are available, then new boxes are created to accommodate the - additional faces. """ - new_boxes_count = max(0, len(self._assets["images"]) - len(self._assets["boxes"])) - if new_boxes_count == 0: - return - logger.debug("new_boxes_count: %s", new_boxes_count) - for _ in range(new_boxes_count): - box = self._canvas.create_rectangle(0, - 0, - self._viewport.face_size, self._viewport.face_size, - outline="#00FF00", - width=2, - state="hidden", - tags=["active_highlighter"]) - logger.trace("Created new highlight_box: %s", box) - self._assets["boxes"].append(box) - - def _update_on_edit(self): - """ Update the active faces on a frame edit. """ - if not self._tk_vars["edited"].get(): - return - self._set_active_objects() - self._update_face() - self._tk_vars["edited"].set(False) - - def _update_face(self): - """ Update the highlighted annotations for faces in the currently selected frame. """ - for face_idx, (image_id, mesh_ids, box_id, det_face), in enumerate( - zip(self._assets["images"], - self._assets["meshes"], - self._assets["boxes"], - self._assets["faces"])): - if det_face is None: - continue - top_left = np.array(self._canvas.coords(image_id)) - coords = (*top_left, *top_left + self._size) - tk_face = self._viewport.get_tk_face(self.frame_index, face_idx, det_face) - self._canvas.itemconfig(image_id, image=tk_face.photo) - self._show_box(box_id, coords) - self._show_mesh(mesh_ids, face_idx, det_face, top_left) - self._last_execution["size"] = self._viewport.face_size - - def _show_box(self, item_id, coordinates): - """ Display the highlight box around the given coordinates. - - Parameters - ---------- - item_id: int - The tkinter canvas object identifier for the highlight box - coordinates: :class:`numpy.ndarray` - The (x, y, x1, y1) coordinates of the top left corner of the box - """ - self._canvas.coords(item_id, *coordinates) - self._canvas.itemconfig(item_id, state="normal") - - def _show_mesh(self, mesh_ids, face_index, detected_face, top_left): - """ Display the mesh annotation for the given face, at the given location. - - Parameters - ---------- - mesh_ids: dict - Dictionary containing the `polygon` and `line` tkinter canvas identifiers that make up - the mesh for the given face - face_index: int - The face index within the frame for the given face - detected_face: :class:`~lib.align.DetectedFace` - The detected face object that contains the landmarks for generating the mesh - top_left: tuple - The (x, y) top left co-ordinates of the mesh's bounding box - """ - state = "normal" if (self._tk_vars["selected_editor"].get() != "Mask" or - self._optional_annotations["mesh"]) else "hidden" - kwargs = dict(polygon=dict(fill="", width=2, outline=self._canvas.control_colors["Mesh"]), - line=dict(fill=self._canvas.control_colors["Mesh"], width=2)) - - edited = (self._tk_vars["edited"].get() and - self._tk_vars["selected_editor"].get() not in ("Mask", "View")) - landmarks = self._viewport.get_landmarks(self.frame_index, - face_index, - detected_face, - top_left, - edited) - for key, kwarg in kwargs.items(): - for idx, mesh_id in enumerate(mesh_ids[key]): - self._canvas.coords(mesh_id, *landmarks[key][idx].flatten()) - self._canvas.itemconfig(mesh_id, state=state, **kwarg) - self._canvas.addtag_withtag(f"active_mesh_{key}", mesh_id) - - class TKFace(): """ An object that holds a single :class:`tkinter.PhotoImage` face, ready for placement in the :class:`Viewport`, Handles the placement of and removal of masks for the face as well as @@ -1013,12 +703,8 @@ class TKFace(): The mask to be applied to the face image. Pass ``None`` if no mask is to be used. Default ``None`` """ - def __init__(self, face, size=128, mask=None): - logger.trace("Initializing %s: (face: %s, size: %s, mask: %s)", - self.__class__.__name__, - face if face is None else face.shape, - size, - mask if mask is None else mask.shape) + def __init__(self, face: np.ndarray, size: int = 128, mask: np.ndarray | None = None) -> None: + logger.trace(parse_class_init(locals())) # type:ignore[attr-defined] self._size = size if face.ndim == 2 and face.shape[1] == 1: self._face = self._image_from_jpg(face) @@ -1026,17 +712,17 @@ class TKFace(): self._face = face[..., 2::-1] self._photo = ImageTk.PhotoImage(self._generate_tk_face_data(mask)) - logger.trace("Initialized %s", self.__class__.__name__) + logger.trace("Initialized %s", self.__class__.__name__) # type:ignore[attr-defined] # << PUBLIC PROPERTIES >> # @property - def photo(self): + def photo(self) -> tk.PhotoImage: """ :class:`tkinter.PhotoImage`: The face in a format that can be placed on the :class:`~tools.manual.faceviewer.frame.FacesViewer` canvas. """ return self._photo # << PUBLIC METHODS >> # - def update(self, face, mask): + def update(self, face: np.ndarray, mask: np.ndarray) -> None: """ Update the :attr:`photo` with the given face and mask. Parameters @@ -1049,7 +735,7 @@ class TKFace(): self._face = face[..., 2::-1] self._photo.paste(self._generate_tk_face_data(mask)) - def update_mask(self, mask): + def update_mask(self, mask: np.ndarray | None) -> None: """ Update the mask in the 4th channel of :attr:`photo` to the given mask. Parameters @@ -1060,7 +746,7 @@ class TKFace(): self._photo.paste(self._generate_tk_face_data(mask)) # << PRIVATE METHODS >> # - def _image_from_jpg(self, face): + def _image_from_jpg(self, face: np.ndarray) -> np.ndarray: """ Convert an encoded jpg into 3 channel BGR image. Parameters @@ -1079,7 +765,7 @@ class TKFace(): face = cv2.resize(face, (self._size, self._size), interpolation=interp) return face[..., 2::-1] - def _generate_tk_face_data(self, mask): + def _generate_tk_face_data(self, mask: np.ndarray | None) -> tk.PhotoImage: """ Create the :class:`tkinter.PhotoImage` from the currant :attr:`_face`. Parameters diff --git a/tools/manual/frameviewer/editor/landmarks.py b/tools/manual/frameviewer/editor/landmarks.py index 49c9c17..452e426 100644 --- a/tools/manual/frameviewer/editor/landmarks.py +++ b/tools/manual/frameviewer/editor/landmarks.py @@ -3,7 +3,7 @@ import gettext import numpy as np -from lib.align import AlignedFace +from lib.align import AlignedFace, LANDMARK_PARTS, LandmarkType from ._base import Editor, logger # LOCALES @@ -67,7 +67,7 @@ class Landmarks(Editor): outline="gray", state="hidden") self._canvas.coords(self._selection_box, 0, 0, 0, 0) - self._drag_data = dict() + self._drag_data = {} if event is not None: self._drag_start(event) @@ -83,7 +83,7 @@ class Landmarks(Editor): landmarks = aligned.landmarks + zoomed_offset # Hide all landmarks and only display selected self._canvas.itemconfig("lm_dsp", state="hidden") - self._canvas.itemconfig("lm_dsp_face_{}".format(face_index), state="normal") + self._canvas.itemconfig(f"lm_dsp_face_{face_index}", state="normal") else: landmarks = self._scale_to_display(face.landmarks_xy) for lm_idx, landmark in enumerate(landmarks): @@ -109,8 +109,8 @@ class Landmarks(Editor): color = self._control_color bbox = (bounding_box[0] - radius, bounding_box[1] - radius, bounding_box[0] + radius, bounding_box[1] + radius) - key = "lm_dsp_{}".format(landmark_index) - kwargs = dict(outline=color, fill=color, width=radius) + key = f"lm_dsp_{landmark_index}" + kwargs = {"outline": color, "fill": color, "width": radius} self._object_tracker(key, "oval", face_index, bbox, kwargs) def _label_landmark(self, bounding_box, face_index, landmark_index): @@ -132,9 +132,9 @@ class Landmarks(Editor): # NB The text must be visible to be able to get the bounding box, so set to hidden # after the bounding box has been retrieved - keys = ["lm_lbl_{}".format(landmark_index), "lm_lbl_bg_{}".format(landmark_index)] - text_kwargs = dict(fill="black", font=("Default", 10), text=str(landmark_index + 1)) - bg_kwargs = dict(fill="#ffffea", outline="black") + keys = [f"lm_lbl_{landmark_index}", f"lm_lbl_bg_{landmark_index}"] + text_kwargs = {"fill": "black", "font": ("Default", 10), "text": str(landmark_index + 1)} + bg_kwargs = {"fill": "#ffffea", "outline": "black"} text_id = self._object_tracker(keys[0], "text", face_index, top_left, text_kwargs) bbox = self._canvas.bbox(text_id) @@ -162,11 +162,11 @@ class Landmarks(Editor): radius = 7 bbox = (bounding_box[0] - radius, bounding_box[1] - radius, bounding_box[0] + radius, bounding_box[1] + radius) - key = "lm_grb_{}".format(landmark_index) - kwargs = dict(outline="", - fill="", - width=1, - dash=(2, 4)) + key = f"lm_grb_{landmark_index}" + kwargs = {"outline": "", + "fill": "", + "width": 1, + "dash": (2, 4)} self._object_tracker(key, "oval", face_index, bbox, kwargs) # << MOUSE HANDLING >> @@ -185,7 +185,7 @@ class Landmarks(Editor): if self._drag_data: self._update_cursor_select_mode(event) else: - objs = self._canvas.find_withtag("lm_grb_face_{}".format(self._globals.face_index) + objs = self._canvas.find_withtag(f"lm_grb_face_{self._globals.face_index}" if self._globals.is_zoomed else "lm_grb") item_ids = set(self._canvas.find_overlapping(event.x - 6, event.y - 6, @@ -226,7 +226,7 @@ class Landmarks(Editor): self._canvas.config(cursor="none") for prefix in ("lm_lbl_", "lm_lbl_bg_"): - tag = "{}{}_face_{}".format(prefix, lm_idx, face_idx) + tag = f"{prefix}{lm_idx}_face_{face_idx}" logger.trace("Displaying: %s tag: %s", self._canvas.type(tag), tag) self._canvas.itemconfig(tag, state="normal") self._mouse_location = obj_idx @@ -271,7 +271,7 @@ class Landmarks(Editor): self._drag_data["start_location"] = (event.x, event.y) self._drag_callback = self._move_selection else: # Reset - self._drag_data = dict() + self._drag_data = {} self._drag_callback = None self._reset_selection(event) @@ -294,7 +294,7 @@ class Landmarks(Editor): self._det_faces.update.post_edit_trigger(self._globals.frame_index, self._mouse_location[0]) self._mouse_location = None - self._drag_data = dict() + self._drag_data = {} elif self._drag_data and self._drag_data.get("selected", False): self._drag_stop_selected() else: @@ -429,15 +429,6 @@ class Mesh(Editor): The _detected_faces data for this manual session """ def __init__(self, canvas, detected_faces): - self._landmark_mapping = dict(mouth_inner=(60, 68), - mouth_outer=(48, 60), - right_eyebrow=(17, 22), - left_eyebrow=(22, 27), - right_eye=(36, 42), - left_eye=(42, 48), - nose=(27, 36), - jaw=(0, 17), - chin=(8, 11)) super().__init__(canvas, detected_faces, None) def update_annotation(self): @@ -452,19 +443,23 @@ class Mesh(Editor): centering="face", size=min(self._globals.frame_display_dims)) landmarks = aligned.landmarks + zoomed_offset + landmark_mapping = LANDMARK_PARTS[aligned.landmark_type] # Hide all meshes and only display selected self._canvas.itemconfig("Mesh", state="hidden") - self._canvas.itemconfig("Mesh_face_{}".format(face_index), state="normal") + self._canvas.itemconfig(f"Mesh_face_{face_index}", state="normal") else: landmarks = self._scale_to_display(face.landmarks_xy) + landmark_mapping = LANDMARK_PARTS[LandmarkType.from_shape(landmarks.shape)] logger.trace("Drawing Landmarks Mesh: (landmarks: %s, color: %s)", landmarks, color) - for idx, (segment, val) in enumerate(self._landmark_mapping.items()): - key = "mesh_{}".format(idx) - pts = landmarks[val[0]:val[1]].flatten() - if segment in ("right_eye", "left_eye", "mouth_inner", "mouth_outer"): - kwargs = dict(fill="", outline=color, width=1) - self._object_tracker(key, "polygon", face_index, pts, kwargs) + for idx, (start, end, fill) in enumerate(landmark_mapping.values()): + key = f"mesh_{idx}" + pts = landmarks[start:end].flatten() + if fill: + kwargs = {"fill": "", "outline": color, "width": 1} + asset = "polygon" else: - self._object_tracker(key, "line", face_index, pts, dict(fill=color, width=1)) + kwargs = {"fill": color, "width": 1} + asset = "line" + self._object_tracker(key, asset, face_index, pts, kwargs) # Place mesh as bottom annotation self._canvas.tag_raise(self.__class__.__name__, "main_image") diff --git a/tools/manual/frameviewer/frame.py b/tools/manual/frameviewer/frame.py index f42d53f..23c953a 100644 --- a/tools/manual/frameviewer/frame.py +++ b/tools/manual/frameviewer/frame.py @@ -42,7 +42,7 @@ class DisplayFrame(ttk.Frame): # pylint:disable=too-many-ancestors self._globals = tk_globals self._det_faces = detected_faces - self._optional_widgets = dict() + self._optional_widgets = {} self._actions_frame = ActionsFrame(self) main_frame = ttk.Frame(self) @@ -74,28 +74,28 @@ class DisplayFrame(ttk.Frame): # pylint:disable=too-many-ancestors @property def _helptext(self): """ dict: {`name`: `help text`} Helptext lookup for navigation buttons """ - return dict( - play=_("Play/Pause (SPACE)"), - beginning=_("Go to First Frame (HOME)"), - prev=_("Go to Previous Frame (Z)"), - next=_("Go to Next Frame (X)"), - end=_("Go to Last Frame (END)"), - extract=_("Extract the faces to a folder... (Ctrl+E)"), - save=_("Save the Alignments file (Ctrl+S)"), - mode=_("Filter Frames to only those Containing the Selected Item (F)"), - distance=_("Set the distance from an 'average face' to be considered misaligned. " - "Higher distances are more restrictive")) + return { + "play": _("Play/Pause (SPACE)"), + "beginning": _("Go to First Frame (HOME)"), + "prev": _("Go to Previous Frame (Z)"), + "next": _("Go to Next Frame (X)"), + "end": _("Go to Last Frame (END)"), + "extract": _("Extract the faces to a folder... (Ctrl+E)"), + "save": _("Save the Alignments file (Ctrl+S)"), + "mode": _("Filter Frames to only those Containing the Selected Item (F)"), + "distance": _("Set the distance from an 'average face' to be considered misaligned. " + "Higher distances are more restrictive")} @property def _btn_action(self): """ dict: {`name`: `action`} Command lookup for navigation buttons """ - actions = dict(play=self._navigation.handle_play_button, - beginning=self._navigation.goto_first_frame, - prev=self._navigation.decrement_frame, - next=self._navigation.increment_frame, - end=self._navigation.goto_last_frame, - extract=self._det_faces.extract, - save=self._det_faces.save) + actions = {"play": self._navigation.handle_play_button, + "beginning": self._navigation.goto_first_frame, + "prev": self._navigation.decrement_frame, + "next": self._navigation.increment_frame, + "end": self._navigation.goto_last_frame, + "extract": self._det_faces.extract, + "save": self._det_faces.save} return actions @property @@ -149,7 +149,7 @@ class DisplayFrame(ttk.Frame): # pylint:disable=too-many-ancestors textvariable=self._globals.tk_transport_index, justify=tk.RIGHT) tbox.pack(padx=0, side=tk.LEFT) - lbl = ttk.Label(lbl_frame, text="/{}".format(max_frame)) + lbl = ttk.Label(lbl_frame, text=f"/{max_frame}") lbl.pack(side=tk.RIGHT) cmd = partial(set_slider_rounding, @@ -165,7 +165,7 @@ class DisplayFrame(ttk.Frame): # pylint:disable=too-many-ancestors command=cmd) nav.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) self._globals.tk_transport_index.trace("w", self._set_frame_index) - return dict(entry=tbox, scale=nav, label=lbl) + return {"entry": tbox, "scale": nav, "label": lbl} def _set_frame_index(self, *args): # pylint:disable=unused-argument """ Set the actual frame index based on current slider position and filter mode. """ @@ -187,7 +187,7 @@ class DisplayFrame(ttk.Frame): # pylint:disable=too-many-ancestors frame = ttk.Frame(self._transport_frame) frame.pack(side=tk.BOTTOM, fill=tk.X) icons = get_images().icons - buttons = dict() + buttons = {} for action in ("play", "beginning", "prev", "next", "end", "save", "extract", "mode"): padx = (0, 6) if action in ("play", "prev", "mode") else (0, 0) side = tk.RIGHT if action in ("extract", "save", "mode") else tk.LEFT @@ -366,7 +366,7 @@ class ActionsFrame(ttk.Frame): # pylint:disable=too-many-ancestors self._buttons = self._add_buttons() self._static_buttons = self._add_static_buttons() self._selected_action = self._set_selected_action_tkvar() - self._optional_buttons = dict() # Has to be set from parent after canvas is initialized + self._optional_buttons = {} # Has to be set from parent after canvas is initialized @property def actions(self): @@ -382,19 +382,19 @@ class ActionsFrame(ttk.Frame): # pylint:disable=too-many-ancestors def key_bindings(self): """ dict: {`key`: `action`}. The mapping of key presses to actions. Keyboard shortcut is the first letter of each action. """ - return {"F{}".format(idx + 1): action for idx, action in enumerate(self._actions)} + return {f"F{idx + 1}": action for idx, action in enumerate(self._actions)} @property def _helptext(self): """ dict: `button key`: `button helptext`. The help text to display for each button. """ inverse_keybindings = {val: key for key, val in self.key_bindings.items()} - retval = dict(View=_("View alignments"), - BoundingBox=_("Bounding box editor"), - ExtractBox=_("Location editor"), - Mask=_("Mask editor"), - Landmarks=_("Landmark point editor")) + retval = {"View": _('View alignments'), + "BoundingBox": _('Bounding box editor'), + "ExtractBox": _("Location editor"), + "Mask": _("Mask editor"), + "Landmarks": _("Landmark point editor")} for item in retval: - retval[item] += " ({})".format(inverse_keybindings[item]) + retval[item] += f" ({inverse_keybindings[item]})" return retval def _configure_styles(self): @@ -415,7 +415,7 @@ class ActionsFrame(ttk.Frame): # pylint:disable=too-many-ancestors """ frame = ttk.Frame(self) frame.pack(side=tk.TOP, fill=tk.Y) - buttons = dict() + buttons = {} for action in self.key_bindings.values(): if action == self._initial_action: btn_style = "actions_selected.TButton" @@ -467,22 +467,24 @@ class ActionsFrame(ttk.Frame): # pylint:disable=too-many-ancestors def _add_static_buttons(self): """ Add the buttons to copy alignments from previous and next frames """ - lookup = dict(copy_prev=(_("Previous"), "C"), copy_next=(_("Next"), "V"), reload=("", "R")) + lookup = {"copy_prev": (_("Previous"), "C"), + "copy_next": (_("Next"), "V"), + "reload": ("", "R")} frame = ttk.Frame(self) frame.pack(side=tk.TOP, fill=tk.Y) sep = ttk.Frame(frame, height=2, relief=tk.RIDGE) sep.pack(fill=tk.X, pady=5, side=tk.TOP) - buttons = dict() + buttons = {} tk_frame_index = self._globals.tk_frame_index for action in ("copy_prev", "copy_next", "reload"): if action == "reload": icon = "reload3" - cmd = lambda f=tk_frame_index: self._det_faces.revert_to_saved(f.get()) # noqa + cmd = lambda f=tk_frame_index: self._det_faces.revert_to_saved(f.get()) # noqa=E731 # pylint:disable=line-too-long,unnecessary-lambda-assignment helptext = _("Revert to saved Alignments ({})").format(lookup[action][1]) else: icon = action direction = action.replace("copy_", "") - cmd = lambda f=tk_frame_index, d=direction: self._det_faces.update.copy( # noqa + cmd = lambda f=tk_frame_index, d=direction: self._det_faces.update.copy( # noqa=E731 # pylint:disable=line-too-long,unnecessary-lambda-assignment f.get(), d) helptext = _("Copy {} Alignments ({})").format(*lookup[action]) state = ["!disabled"] if action == "copy_next" else ["disabled"] @@ -506,10 +508,10 @@ class ActionsFrame(ttk.Frame): # pylint:disable=too-many-ancestors for count in face_count_per_index[:position]) next_exists = position != -1 and any(count != 0 for count in face_count_per_index[position + 1:]) - states = dict(prev=["!disabled"] if prev_exists else ["disabled"], - next=["!disabled"] if next_exists else ["disabled"]) + states = {"prev": ["!disabled"] if prev_exists else ["disabled"], + "next": ["!disabled"] if next_exists else ["disabled"]} for direction in ("prev", "next"): - self._static_buttons["copy_{}".format(direction)].state(states[direction]) + self._static_buttons[f"copy_{direction}"].state(states[direction]) def _disable_enable_reload_button(self, *args): # pylint:disable=unused-argument """ Disable or enable the static buttons """ @@ -549,12 +551,12 @@ class ActionsFrame(ttk.Frame): # pylint:disable=too-many-ancestors helptext = action["helptext"] hotkey = action["hotkey"] - helptext += "" if hotkey is None else " ({})".format(hotkey.upper()) + helptext += "" if hotkey is None else f" ({hotkey.upper()})" Tooltip(button, text=helptext) self._optional_buttons.setdefault( - name, dict())[button] = dict(hotkey=hotkey, - group=group, - tk_var=action["tk_var"]) + name, {})[button] = {"hotkey": hotkey, + "group": group, + "tk_var": action["tk_var"]} self._optional_buttons[name]["frame"] = frame self._display_optional_buttons() @@ -652,9 +654,9 @@ class FrameViewer(tk.Canvas): # pylint:disable=too-many-ancestors self._actions = actions self._tk_action_var = tk_action_var self._image = BackgroundImage(self) - self._editor_globals = dict(control_tk_vars=dict(), - annotation_formats=dict(), - key_bindings=dict()) + self._editor_globals = {"control_tk_vars": {}, + "annotation_formats": {}, + "key_bindings": {}} self._max_face_count = 0 self._editors = self._get_editors() self._add_callbacks() @@ -695,11 +697,11 @@ class FrameViewer(tk.Canvas): # pylint:disable=too-many-ancestors @property def editor_display(self): """ dict: List of editors and any additional annotations they should display. """ - return dict(View=["BoundingBox", "ExtractBox", "Landmarks", "Mesh"], - BoundingBox=["Mesh"], - ExtractBox=["Mesh"], - Landmarks=["ExtractBox", "Mesh"], - Mask=[]) + return {"View": ["BoundingBox", "ExtractBox", "Landmarks", "Mesh"], + "BoundingBox": ["Mesh"], + "ExtractBox": ["Mesh"], + "Landmarks": ["ExtractBox", "Mesh"], + "Mask": []} @property def offset(self): @@ -719,7 +721,7 @@ class FrameViewer(tk.Canvas): # pylint:disable=too-many-ancestors dict The {`action`: :class:`Editor`} dictionary of editors for :attr:`_actions` name. """ - editors = dict() + editors = {} for editor_name in self._actions + ("Mesh", ): editor = eval(editor_name)(self, # pylint:disable=eval-used self._det_faces) @@ -797,7 +799,7 @@ class FrameViewer(tk.Canvas): # pylint:disable=too-many-ancestors self._max_face_count = current_face_count return for idx in range(current_face_count, self._max_face_count): - tag = "face_{}".format(idx) + tag = f"face_{idx}" if any(self.itemcget(item_id, "state") != "hidden" for item_id in self.find_withtag(tag)): logger.debug("Hiding face tag '%s'", tag) diff --git a/tools/manual/manual.py b/tools/manual/manual.py index 05d32a2..c31683e 100644 --- a/tools/manual/manual.py +++ b/tools/manual/manual.py @@ -19,7 +19,7 @@ from lib.gui.utils import get_images, get_config, initialize_config, initialize_ from lib.image import SingleFrameLoader, read_image_meta from lib.multithreading import MultiThread from lib.utils import handle_deprecated_cliopts, VIDEO_EXTENSIONS -from plugins.extract.pipeline import Extractor, ExtractMedia +from plugins.extract import ExtractMedia, Extractor from .detected_faces import DetectedFaces from .faceviewer.frame import FacesFrame @@ -678,8 +678,8 @@ class Aligner(): @property def _feed_face(self) -> ExtractMedia: - """ :class:`plugins.extract.pipeline.ExtractMedia`: The current face for feeding into the - aligner, formatted for the pipeline """ + """ :class:`~plugins.extract.extract_media.ExtractMedia`: The current face for feeding into + the aligner, formatted for the pipeline """ assert self._frame_index is not None assert self._face_index is not None assert self._detected_faces is not None diff --git a/tools/mask/loader.py b/tools/mask/loader.py index 0202725..8f50d81 100644 --- a/tools/mask/loader.py +++ b/tools/mask/loader.py @@ -13,7 +13,7 @@ from tqdm import tqdm from lib.align import DetectedFace, update_legacy_png_header from lib.align.alignments import AlignmentFileDict from lib.image import FacesLoader, ImagesLoader -from plugins.extract.pipeline import ExtractMedia +from plugins.extract import ExtractMedia if T.TYPE_CHECKING: from lib.align import Alignments diff --git a/tools/mask/mask.py b/tools/mask/mask.py index 9249a9d..c3619e0 100644 --- a/tools/mask/mask.py +++ b/tools/mask/mask.py @@ -11,7 +11,7 @@ from multiprocessing import Process from lib.align import Alignments from lib.utils import handle_deprecated_cliopts, VIDEO_EXTENSIONS -from plugins.extract.pipeline import ExtractMedia +from plugins.extract import ExtractMedia from .loader import Loader from .mask_import import Import @@ -239,7 +239,7 @@ class _Mask: Parameters ---------- - media: :class:`~plugins.extract.pipeline.ExtractMedia` + media: :class:`~plugins.extract.extract_media.ExtractMedia` The extract media holding the faces to output """ filename = os.path.basename(media.frame_metadata["source_filename"] diff --git a/tools/mask/mask_generate.py b/tools/mask/mask_generate.py index a1a7f62..eb3cd6a 100644 --- a/tools/mask/mask_generate.py +++ b/tools/mask/mask_generate.py @@ -8,13 +8,13 @@ import typing as T from lib.image import encode_image, ImagesSaver from lib.multithreading import MultiThread -from plugins.extract.pipeline import Extractor +from plugins.extract import Extractor if T.TYPE_CHECKING: from lib.align import Alignments, DetectedFace from lib.align.alignments import PNGHeaderDict from lib.queue_manager import EventQueue - from plugins.extract.pipeline import ExtractMedia + from plugins.extract import ExtractMedia from .loader import Loader diff --git a/tools/mask/mask_import.py b/tools/mask/mask_import.py index e9a0f4b..4192ce0 100644 --- a/tools/mask/mask_import.py +++ b/tools/mask/mask_import.py @@ -18,7 +18,7 @@ from lib.utils import get_image_paths if T.TYPE_CHECKING: import numpy as np from .loader import Loader - from plugins.extract.pipeline import ExtractMedia + from plugins.extract import ExtractMedia from lib.align import Alignments, DetectedFace from lib.align.alignments import PNGHeaderDict from lib.align.aligned_face import CenteringType @@ -306,7 +306,7 @@ class Import: Parameters ---------- - media: :class:`~plugins.extract.pipeline.ExtractMedia` + media: :class:`~plugins.extract.extract_media.ExtractMedia` The extract media object containing the face(s) to import the mask for mask: :class:`numpy.ndarray` @@ -361,7 +361,7 @@ class Import: Parameters ---------- - media: :class:`~plugins.extract.pipeline.ExtractMedia` + media: :class:`~plugins.extract.extract_media.ExtractMedia` The extract media object containing the face(s) to import the mask for mask: :class:`numpy.ndarray` @@ -384,7 +384,7 @@ class Import: Parameters ---------- - media: :class:`~plugins.extract.pipeline.ExtractMedia` + media: :class:`~plugins.extract.extract_media.ExtractMedia` The extract media object containing the face(s) to import the mask for """ mask_file = self._mapping.get(os.path.basename(media.filename)) diff --git a/tools/preview/preview.py b/tools/preview/preview.py index e023073..4b60ba9 100644 --- a/tools/preview/preview.py +++ b/tools/preview/preview.py @@ -24,7 +24,7 @@ from lib.queue_manager import queue_manager from scripts.fsmedia import Alignments, Images from scripts.convert import Predict, ConvertItem -from plugins.extract.pipeline import ExtractMedia +from plugins.extract import ExtractMedia from .control_panels import ActionFrame, ConfigTools, OptionsBook from .viewer import FacesDisplay, ImagesCanvas diff --git a/tools/sort/sort_methods.py b/tools/sort/sort_methods.py index 4a15010..f2a4b29 100644 --- a/tools/sort/sort_methods.py +++ b/tools/sort/sort_methods.py @@ -16,7 +16,7 @@ import cv2 import numpy as np from tqdm import tqdm -from lib.align import AlignedFace, DetectedFace +from lib.align import AlignedFace, DetectedFace, LandmarkType from lib.image import FacesLoader, ImagesLoader, read_image_meta_batch, update_existing_metadata from lib.utils import FaceswapError from plugins.extract.recognition.vgg_face2 import Cluster, Recognition as VGGFace @@ -217,6 +217,8 @@ class SortMethod(): Set to ``True`` if this class is going to be called exclusively for binning. Default: ``False`` """ + _log_mask_once = False + def __init__(self, arguments: Namespace, loader_type: T.Literal["face", "meta", "all"] = "meta", @@ -454,12 +456,22 @@ class SortMethod(): centering="legacy", size=256, is_aligned=True) - mask = det_face.mask["components"] + assert aln_face.face is not None + + mask = det_face.mask.get("components", det_face.mask.get("extended", None)) + + if mask is None and not cls._log_mask_once: + logger.warning("No masks are available for masking the data. Results are likely to be " + "sub-standard") + cls._log_mask_once = True + + if mask is None: + return aln_face.face + mask.set_sub_crop(aln_face.pose.offset[mask.stored_centering], aln_face.pose.offset["legacy"], centering="legacy") nmask = cv2.resize(mask.mask, (256, 256), interpolation=cv2.INTER_CUBIC)[..., None] - assert aln_face.face is not None return np.minimum(aln_face.face, nmask) @@ -832,6 +844,11 @@ class SortFace(SortMethod): Set to ``True`` if this class is going to be called exclusively for binning. Default: ``False`` """ + + _logged_lm_count_once = False + _warning = ("Extracted faces do not contain facial landmark data. Results sorted by this " + "method are likely to be sub-standard.") + def __init__(self, arguments: Namespace, is_group: bool = False) -> None: super().__init__(arguments, loader_type="all", is_group=is_group) self._vgg_face = VGGFace(exclude_gpus=arguments.exclude_gpus) @@ -872,6 +889,11 @@ class SortFace(SortMethod): if alignments.get("identity", {}).get("vggface2"): embedding = np.array(alignments["identity"]["vggface2"], dtype="float32") + + if not self._logged_lm_count_once and len(alignments["landmarks_xy"]) == 4: + logger.warning(self._warning) + self._logged_lm_count_once = True + self._result.append((filename, embedding)) return @@ -880,11 +902,17 @@ class SortFace(SortMethod): "Sorting by this method will be quicker next time") self._output_update_info = False - face = AlignedFace(np.array(alignments["landmarks_xy"], dtype="float32"), - image=image, - centering="legacy", - size=self._vgg_face.input_size, - is_aligned=True).face + a_face = AlignedFace(np.array(alignments["landmarks_xy"], dtype="float32"), + image=image, + centering="legacy", + size=self._vgg_face.input_size, + is_aligned=True) + + if a_face.landmark_type == LandmarkType.LM_2D_4 and not self._logged_lm_count_once: + logger.warning(self._warning) + self._logged_lm_count_once = True + + face = a_face.face assert face is not None embedding = self._vgg_face.predict(face[None, ...])[0] alignments.setdefault("identity", {})["vggface2"] = embedding.tolist() diff --git a/tools/sort/sort_methods_aligned.py b/tools/sort/sort_methods_aligned.py index 34275ef..8f0ff0b 100644 --- a/tools/sort/sort_methods_aligned.py +++ b/tools/sort/sort_methods_aligned.py @@ -11,7 +11,7 @@ import typing as T import numpy as np from tqdm import tqdm -from lib.align import AlignedFace +from lib.align import AlignedFace, LandmarkType from lib.utils import FaceswapError from .sort_methods import SortMethod @@ -36,6 +36,9 @@ class SortAlignedMetric(SortMethod): Set to ``True`` if this class is going to be called exclusively for binning. Default: ``False`` """ + + _logged_lm_count_once: bool = False + def _get_metric(self, aligned_face: AlignedFace) -> np.ndarray | float: """ Obtain the correct metric for the given sort method" @@ -85,6 +88,12 @@ class SortAlignedMetric(SortMethod): raise FaceswapError(msg) face = AlignedFace(np.array(alignments["landmarks_xy"], dtype="float32")) + if (not self._logged_lm_count_once + and face.landmark_type == LandmarkType.LM_2D_4 + and self.__class__.__name__ != "SortSize"): + logger.warning("You have selected to sort by an aligned metric, but at least one face " + "does not contain facial landmark data. This probably won't work") + self._logged_lm_count_once = True self._result.append((filename, self._get_metric(face)))