1
0
Fork 0
mirror of https://github.com/deepfakes/faceswap synced 2025-06-09 04:36:50 -04:00
faceswap/tools/sort/sort_methods_aligned.py
torzdf 1c081aea7d
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
2024-04-15 12:19:15 +01:00

393 lines
14 KiB
Python

#!/usr/bin/env python3
""" Sorting methods that use the properties of a :class:`lib.align.AlignedFace` object to obtain
their sorting metrics.
"""
from __future__ import annotations
import logging
import operator
import sys
import typing as T
import numpy as np
from tqdm import tqdm
from lib.align import AlignedFace, LandmarkType
from lib.utils import FaceswapError
from .sort_methods import SortMethod
if T.TYPE_CHECKING:
from argparse import Namespace
from lib.align.alignments import PNGHeaderAlignmentsDict
logger = logging.getLogger(__name__)
class SortAlignedMetric(SortMethod):
""" Sort by comparison of metrics stored in an Aligned Face objects. This is a parent class
for sort by aligned metrics methods. Individual methods should inherit from this class
Parameters
----------
arguments: :class:`argparse.Namespace`
The command line arguments passed to the sort process
sort_reverse: bool, optional
``True`` if the sorted results should be in reverse order. Default: ``True``
is_group: bool, optional
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"
Parameters
----------
aligned_face: :class:`lib.align.AlignedFace`
The aligned face to extract the metric from
Returns
-------
float or :class:`numpy.ndarray`
The metric for the current face based on chosen sort method
"""
raise NotImplementedError
def sort(self) -> None:
""" Sort by metric score. Order in reverse for distance sort. """
logger.info("Sorting...")
self._result = sorted(self._result, key=operator.itemgetter(1), reverse=True)
def score_image(self,
filename: str,
image: np.ndarray | None,
alignments: PNGHeaderAlignmentsDict | None) -> None:
""" Score a single image for sort method: "distance", "yaw", "pitch" or "size" and add the
result to :attr:`_result`
Parameters
----------
filename: str
The filename of the currently processing image
image: :class:`np.ndarray` or ``None``
A face image loaded from disk or ``None``
alignments: dict or ``None``
The alignments dictionary for the aligned face or ``None``
"""
if self._log_once:
msg = "Grouping" if self._is_group else "Sorting"
logger.info("%s by %s...", msg, self._method)
self._log_once = False
if not alignments:
msg = ("The images to be sorted do not contain alignment data. Images must have "
"been generated by Faceswap's Extract process.\nIf you are sorting an "
"older faceset, then you should re-extract the faces from your source "
"alignments file to generate this data.")
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)))
class SortDistance(SortAlignedMetric):
""" Sorting mechanism for sorting faces from small to large """
def _get_metric(self, aligned_face: AlignedFace) -> float:
""" Obtain the distance from mean face metric for the given face
Parameters
----------
aligned_face: :class:`lib.align.AlignedFace`
The aligned face to extract the metric from
Returns
-------
float
The distance metric for the current face
"""
return aligned_face.average_distance
def sort(self) -> None:
""" Override default sort to sort in ascending order. """
logger.info("Sorting...")
self._result = sorted(self._result, key=operator.itemgetter(1), reverse=False)
def binning(self) -> list[list[str]]:
""" Create bins to split linearly from the lowest to the highest sample value
Returns
-------
list
List of bins of filenames
"""
return self._binning_linear_threshold(multiplier=100)
class SortPitch(SortAlignedMetric):
""" Sorting mechansim for sorting a face by pitch (down to up) """
def _get_metric(self, aligned_face: AlignedFace) -> float:
""" Obtain the pitch metric for the given face
Parameters
----------
aligned_face: :class:`lib.align.AlignedFace`
The aligned face to extract the metric from
Returns
-------
float
The pitch metric for the current face
"""
return aligned_face.pose.pitch
def binning(self) -> list[list[str]]:
""" Create bins from 0 degrees to 180 degrees based on number of bins
Allocate item to bin when it is in range of one of the pre-allocated bins
Returns
-------
list
List of bins of filenames
"""
thresholds = np.linspace(90, -90, self._num_bins + 1)
# Start bin names from 0 for more intuitive experience
names = np.flip(thresholds.astype("int")) + 90
self._bin_names = [f"{self._method}_"
f"{idx:03d}_{int(names[idx])}"
f"degs_to_{int(names[idx + 1])}degs"
for idx in range(self._num_bins)]
bins: list[list[str]] = [[] for _ in range(self._num_bins)]
for filename, result in self._result:
result = np.clip(result, -90.0, 90.0)
bin_idx = next(bin_id for bin_id, thresh in enumerate(thresholds)
if result >= thresh) - 1
bins[bin_idx].append(filename)
return bins
class SortYaw(SortPitch):
""" Sorting mechansim for sorting a face by yaw (left to right). Same logic as sort pitch, but
with different metric """
def _get_metric(self, aligned_face: AlignedFace) -> float:
""" Obtain the yaw metric for the given face
Parameters
----------
aligned_face: :class:`lib.align.AlignedFace`
The aligned face to extract the metric from
Returns
-------
float
The yaw metric for the current face
"""
return aligned_face.pose.yaw
class SortRoll(SortPitch):
""" Sorting mechansim for sorting a face by roll (rotation). Same logic as sort pitch, but
with different metric """
def _get_metric(self, aligned_face: AlignedFace) -> float:
""" Obtain the roll metric for the given face
Parameters
----------
aligned_face: :class:`lib.align.AlignedFace`
The aligned face to extract the metric from
Returns
-------
float
The yaw metric for the current face
"""
return aligned_face.pose.roll
class SortSize(SortAlignedMetric):
""" Sorting mechanism for sorting faces from small to large """
def _get_metric(self, aligned_face: AlignedFace) -> float:
""" Obtain the size metric for the given face
Parameters
----------
aligned_face: :class:`lib.align.AlignedFace`
The aligned face to extract the metric from
Returns
-------
float
The size metric for the current face
"""
roi = aligned_face.original_roi
size = ((roi[1][0] - roi[0][0]) ** 2 + (roi[1][1] - roi[0][1]) ** 2) ** 0.5
return size
def binning(self) -> list[list[str]]:
""" Create bins to split linearly from the lowest to the highest sample value
Allocate item to bin when it is in range of one of the pre-allocated bins
Returns
-------
list
List of bins of filenames
"""
return self._binning_linear_threshold(units="px")
class SortFaceCNN(SortAlignedMetric):
""" Sort by landmark similarity or dissimilarity
Parameters
----------
arguments: :class:`argparse.Namespace`
The command line arguments passed to the sort process
is_group: bool, optional
Set to ``True`` if this class is going to be called exclusively for binning.
Default: ``False``
"""
def __init__(self, arguments: Namespace, is_group: bool = False) -> None:
super().__init__(arguments, is_group=is_group)
self._is_dissim = self._method == "face-cnn-dissim"
self._threshold: float = 7.2 if arguments.threshold < 1.0 else arguments.threshold
def _get_metric(self, aligned_face: AlignedFace) -> np.ndarray:
""" Obtain the xy aligned landmarks for the face"
Parameters
----------
aligned_face: :class:`lib.align.AlignedFace`
The aligned face to extract the metric from
Returns
-------
float
The metric for the current face based on chosen sort method
"""
return aligned_face.landmarks
def sort(self) -> None:
""" Sort by landmarks. """
logger.info("Comparing landmarks and sorting...")
if self._is_dissim:
self._sort_landmarks_dissim()
return
self._sort_landmarks_ssim()
def _sort_landmarks_ssim(self) -> None:
""" Sort landmarks by similarity """
img_list_len = len(self._result)
for i in tqdm(range(0, img_list_len - 1), desc="Comparing", file=sys.stdout, leave=False):
min_score = float("inf")
j_min_score = i + 1
for j in range(i + 1, img_list_len):
fl1 = self._result[i][1]
fl2 = self._result[j][1]
score = np.sum(np.absolute((fl2 - fl1).flatten()))
if score < min_score:
min_score = score
j_min_score = j
(self._result[i + 1], self._result[j_min_score]) = (self._result[j_min_score],
self._result[i + 1])
def _sort_landmarks_dissim(self) -> None:
""" Sort landmarks by dissimilarity """
logger.info("Comparing landmarks...")
img_list_len = len(self._result)
for i in tqdm(range(0, img_list_len - 1), desc="Comparing", file=sys.stdout, leave=False):
score_total = 0
for j in range(i + 1, img_list_len):
if i == j:
continue
fl1 = self._result[i][1]
fl2 = self._result[j][1]
score_total += np.sum(np.absolute((fl2 - fl1).flatten()))
self._result[i][2] = score_total
logger.info("Sorting...")
self._result = sorted(self._result, key=operator.itemgetter(2), reverse=True)
def binning(self) -> list[list[str]]:
""" Group into bins by CNN face similarity
Returns
-------
list
List of bins of filenames
"""
msg = "dissimilarity" if self._is_dissim else "similarity"
logger.info("Grouping by face-cnn %s...", msg)
# Groups are of the form: group_num -> reference faces
reference_groups: dict[int, list[np.ndarray]] = {}
# Bins array, where index is the group number and value is
# an array containing the file paths to the images in that group.
bins: list[list[str]] = []
# Comparison threshold used to decide how similar
# faces have to be to be grouped together.
# It is multiplied by 1000 here to allow the cli option to use smaller
# numbers.
threshold = self._threshold * 1000
img_list_len = len(self._result)
for i in tqdm(range(0, img_list_len - 1),
desc="Grouping",
file=sys.stdout,
leave=False):
fl1 = self._result[i][1]
current_key = -1
current_score = float("inf")
for key, references in reference_groups.items():
try:
score = self._get_avg_score(fl1, references)
except TypeError:
score = float("inf")
except ZeroDivisionError:
score = float("inf")
if score < current_score:
current_key, current_score = key, score
if current_score < threshold:
reference_groups[current_key].append(fl1[0])
bins[current_key].append(self._result[i][0])
else:
reference_groups[len(reference_groups)] = [self._result[i][1]]
bins.append([self._result[i][0]])
return bins
@classmethod
def _get_avg_score(cls, face: np.ndarray, references: list[np.ndarray]) -> float:
""" Return the average CNN similarity score between a face and reference images
Parameters
----------
face: :class:`numpy.ndarray`
The face to check against reference images
references: list
List of reference arrays to compare the face against
Returns
-------
float
The average score between the face and the references
"""
scores = []
for ref in references:
score = np.sum(np.absolute((ref - face).flatten()))
scores.append(score)
return sum(scores) / len(scores)