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
This commit is contained in:
torzdf 2024-04-15 12:19:15 +01:00 committed by GitHub
parent 118e615724
commit 1c081aea7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
62 changed files with 3331 additions and 1735 deletions

View File

@ -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:

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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]

View File

@ -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:

111
lib/align/constants.py Normal file
View File

@ -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. """

View File

@ -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:

187
lib/align/pose.py Normal file
View File

@ -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

View File

@ -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,

View File

@ -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:
<video_filename>_<frame_number>.<video_extension>
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

View File

@ -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

View File

@ -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"

View File

@ -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 "

View File

@ -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:16+0000\n"
"POT-Creation-Date: 2024-04-12 11:56+0100\n"
"PO-Revision-Date: 2024-04-12 12:00+0100\n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: ko_KR\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 "데이터"
@ -63,12 +63,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 "플러그인들"
@ -82,7 +82,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' 또는 '설정 > 추출 플러"
"그인 설정'에서 설정이 가능합니다:\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] 이것은 레거시 모델을 로드 중이거나 모델 폴더에 여러 모델이 있는 "
#~ "경우에만 선택되어야 합니다"

View File

@ -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:05+0000\n"
"POT-Creation-Date: 2024-04-12 12:10+0100\n"
"PO-Revision-Date: 2024-04-12 12:17+0100\n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: ko_KR\n"
@ -63,17 +63,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."
@ -107,6 +113,11 @@ msgstr ""
"을 전달해야 합니다.\n"
"L|'draw': 선택한 폴더/비디오의 프레임에 특징점을 그립니다. 출력을 저장할 하"
"위 폴더가 프레임 폴더 내에 생성됩니다.{0}\n"
"L|'export': 정렬 파일의 내용을 JSON 파일로 내보내십시오. 외부 도구에서 정렬 "
"정보를 편집 한 다음 FaceSwap의 추출물 'Import'플러그인을 사용하여 다시 인상하"
"는 데 사용할 수 있습니다. 참고 : 마스크 및 ID 벡터는 내보내기 파일에 포함되"
"지 않으므로 JSON 파일이 다시 FaceSwap으로 가져 오면 다시 생성됩니다. 모든 데"
"이터는 캔버스의 왼쪽 상단에있는 원점 (0, 0)으로 내 보냅니다.\n"
"L|'extract': alignments 데이터를 기반으로 소스 프레임/비디오에서 얼굴을 재추"
"출합니다. 이것은 얼굴을 재감지하는 것보다 훨씬 더 빠릅니다. '-een'(--extract-"
"every-n) 매개 변수를 전달하여 모든 n번째 프레임을 추출할 수 있습니다.{1}\n"
@ -131,7 +142,7 @@ msgstr ""
"L| 'spatial': 공간 및 시간 필터링을 수행하여 alignments를 원활하게 수행합니다"
"(실험적!)."
#: 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"
@ -145,12 +156,12 @@ msgstr ""
"L|'파일': 프레임 목록을 텍스트 파일(소스 디렉토리에 저장)로 출력합니다.\n"
"L|'이동': 검색된 항목을 원본 디렉토리 내의 하위 폴더로 이동합니다."
#: 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 "
@ -163,11 +174,11 @@ msgstr ""
"다. 지정된 얼굴 폴더에 alignments 파일이 생성될 때 모든 작업은 'from-"
"faces'를 제외한 alignments 파일이 필요로 합니다."
#: 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"
@ -203,12 +214,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 "
@ -218,11 +229,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 "[Extract only] 추출된 얼굴들의 결과 크기입니다."
#: 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 "

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-03-28 18:11+0000\n"
"POT-Creation-Date: 2024-04-12 11:56+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\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 ""

View File

@ -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 ""
#~ "[ОТБРОШЕН] Этот параметр необходимо выбрать только в том случае, если "
#~ "загружается устаревшая модель или если в папке моделей имеется несколько "
#~ "моделей"

View File

@ -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 "

View File

@ -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 <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\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 "

View File

@ -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

View File

@ -0,0 +1,4 @@
#!/usr/bin/env python3
""" Package for Faceswap's extraction pipeline """
from .extract_media import ExtractMedia
from .pipeline import Extractor

View File

@ -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 `<plugin_type>` 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:

View File

@ -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)

View File

@ -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))

View File

@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""
The default options for the faceswap Import Alignments plugin.
Defaults files should be named <plugin_name>_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:
{<option_name>: {<metadata>}}
<option_name> should always be lower text.
<metadata> dictionary requirements are listed below.
The following keys are expected for the _DEFAULTS <metadata> 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: <class 'int'>, <class 'float'>,
<class 'str'>, <class 'bool'>.
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 <class 'str'> 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 <choices> are defined, this indicates that the GUI should use
radio buttons rather than a combobox to display this option.
min_max: [partial] For <class 'int'> and <class 'float'> 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 <class 'int'> and <class 'float'> 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
}
}

View File

@ -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

View File

@ -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))

View File

@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""
The default options for the faceswap Import Alignments plugin.
Defaults files should be named <plugin_name>_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:
{<option_name>: {<metadata>}}
<option_name> should always be lower text.
<metadata> dictionary requirements are listed below.
The following keys are expected for the _DEFAULTS <metadata> 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: <class 'int'>, <class 'float'>,
<class 'str'>, <class 'bool'>.
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 <class 'str'> 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 <choices> are defined, this indicates that the GUI should use
radio buttons rather than a combobox to display this option.
min_max: [partial] For <class 'int'> and <class 'float'> 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 <class 'int'> and <class 'float'> 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
}
}

View File

@ -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)

View File

@ -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,

View File

@ -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")

View File

@ -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")

View File

@ -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)

View File

@ -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.
"""

View File

@ -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.

View File

@ -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

View File

@ -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):

View File

@ -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",

View File

@ -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}"

View File

@ -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

View File

@ -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],

View File

@ -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.

View File

@ -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("<Configure>", 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("<Configure>", 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("<Button-4>", self._scroll)
@ -367,7 +389,7 @@ class FacesViewer(tk.Canvas): # pylint:disable=too-many-ancestors
else:
self.bind("<MouseWheel>", 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

View File

@ -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("<Leave>", lambda e: self._clear())
self._canvas.bind("<Motion>", self.on_hover)
self._canvas.bind("<ButtonPress-1>", 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)

File diff suppressed because it is too large Load Diff

View File

@ -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")

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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"]

View File

@ -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

View File

@ -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))

View File

@ -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

View File

@ -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()

View File

@ -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)))