Files

1063 lines
44 KiB
Python

#!/usr/bin python3
""" Main entry point to the extract process of FaceSwap """
from __future__ import annotations
import logging
import os
import sys
import typing as T
from time import sleep
from dataclasses import asdict, dataclass
import cv2
import numpy as np
import torch
from tqdm import tqdm
from lib.align.aligned_utils import (batch_adjust_matrices, batch_align, batch_resize,
batch_transform, get_adjusted_center, get_sub_crop_size)
from lib.align.objects import AlignmentsEntry, FileAlignments, PNGHeader, PNGSource
from lib.align.constants import EXTRACT_RATIOS, LandmarkType, MEAN_FACE
from lib.align.detected_face import DetectedFace
from lib.align.pose import get_camera_matrix, get_xyz_2d, Batch3D
from lib.infer import Detect, Align, Identity, Mask, File, Profiler
from lib.infer.identity import FilterLoader
from lib.infer.objects import FrameFaces, frame_faces_to_alignment
from lib.image import encode_image, ImagesLoader, ImagesSaver
from lib.logger import parse_class_init
from lib.multithreading import FSThread
from lib.utils import get_folder, get_module_objects, handle_deprecated_cli_opts, IMAGE_EXTENSIONS
from lib.video import VIDEO_EXTENSIONS
from .fs_media import Alignments, finalize
if T.TYPE_CHECKING:
import numpy.typing as npt
from argparse import Namespace
from lib.align.objects import PNGAlignments
from lib.infer.runner import ExtractRunner
from lib.multithreading import ErrorState
logger = logging.getLogger(__name__)
@dataclass
class BatchInfo:
""" Holds information about each input batch being processed through extract
Parameters
----------
loader
The images loader for the batch
alignments
The alignments for the input
"""
loader: Loader
"""The images loader for the batch"""
alignments: Alignments
"""The alignments for the input"""
class Extract:
""" The Faceswap Face Extraction Process.
The extraction process is responsible for detecting faces in a series of images/video, aligning
them and optionally collecting further data about each face leveraging various user selected
plugins
Parameters
----------
arguments
The arguments to be passed to the extraction process as generated from Faceswap's command
line arguments
"""
def __init__(self, arguments: Namespace) -> None:
logger.debug(parse_class_init(locals()))
args = handle_deprecated_cli_opts(arguments,
additional={"K": ("to skip saving faces", True, None)})
args = self._validate_compatible_args(args)
input_locations = self._get_input_locations(args.input_dir, args.batch_mode)
self._validate_batch_mode(args.batch_mode, input_locations, args)
self._configure_torch(args.compile)
self._face_filter = FilterLoader(args.ref_threshold, args.filter, args.nfilter)
self._pipeline = self._load_pipeline(args)
file_input = args.detector == "file" or args.aligner == "file"
save_alignments = self._should_save_alignments(args)
self._batches = [BatchInfo(ld := Loader(self._pipeline,
input_location,
file_input,
args.extract_every_n,
args.skip_existing,
args.skip_faces,
idx == len(input_locations) - 1),
Alignments(args.alignments_path,
ld.location,
is_extract=True,
skip_existing_frames=args.skip_existing,
skip_existing_faces=arguments.skip_faces,
plugin_is_file=file_input,
save_alignments=save_alignments,
input_is_video=ld.is_video))
for idx, input_location in enumerate(input_locations)]
self._output = Output(self._pipeline,
args.output_dir,
args.size,
args.min_scale,
self._batches,
args.save_interval,
args.debug_landmarks)
@classmethod
def _get_input_locations(cls, input_location: str, batch_mode: bool) -> list[str]:
""" Obtain the full path to input locations. Will be a list of locations if batch mode is
selected, or a list containing a single location if batch mode is not selected.
Parameters
----------
input_location
The full path to the input location. Either a video file, a folder of images or a
folder containing either/or videos and sub-folders of images (if batch mode is
selected)
batch_mode
``True`` if extract is running in batch mode
Returns
-------
The list of input location paths
"""
if not batch_mode:
return [input_location]
if os.path.isfile(input_location):
logger.warning("Batch mode selected but input is not a folder. Switching to normal "
"mode")
return [input_location]
retval = [os.path.join(input_location, fname)
for fname in os.listdir(input_location)
if (os.path.isdir(os.path.join(input_location, fname)) # folder images
and any(os.path.splitext(iname)[-1].lower() in IMAGE_EXTENSIONS
for iname in os.listdir(os.path.join(input_location, fname))))
or os.path.splitext(fname)[-1].lower() in VIDEO_EXTENSIONS] # video
retval = list(sorted(retval))
logger.debug("[Extract] Input locations: %s", retval)
return retval
@classmethod
def _validate_compatible_args(cls, args: Namespace) -> Namespace:
"""Some cli arguments are not compatible with each other. If conflicting arguments have
been selected, log a warning and make necessary changes
Parameters
----------
args
The command line arguments to be checked and updated for conflicts
Returns
-------
The updated command line arguments
"""
# Can't run a detector if importing landmarks
if args.aligner == "file" and args.detector != "file":
logger.warning("Detecting faces is not compatible with importing landmarks from a "
"file. Setting Detector to 'file'")
args.detector = "file"
# Impossible to skip existing when not running detection
if args.skip_existing and args.detector == "file":
logger.warning("Skipping existing frames is not compatible with importing from a file "
"for detection. Disabling 'skip_existing'")
args.skip_existing = False
# Impossible to get missing faces when we do not have a detector or aligner
if args.skip_faces and (args.detector == "file" or args.aligner == "file"):
logger.warning("Skipping existing faces is not compatible with importing from a file. "
"Disabling 'skip_existing_faces'")
args.skip_faces = False
# Face filtering needs a recognition plugin
if (args.filter or args.nfilter) and not args.identity:
logger.warning("Face-filtering is enabled, but an identity plugin has not been "
"selected. Selecting 'T-Face' plugin")
args.identity = ["t-face"]
# We can only use 1 identity for face filtering, so we select the first given
if (args.filter or args.nfilter) and len(args.identity) > 1:
logger.warning("Face-filtering is enabled, but multiple identity plugins have been "
"selected. Using '%s' for filtering", args.identity[0])
return args
def _validate_batch_mode(self, batch_mode: bool,
input_locations: list[str],
args: Namespace) -> None:
""" Validate the command line arguments.
If batch-mode selected and there is only one object to extract from, then batch mode is
disabled
If processing in batch mode, some of the given arguments may not make sense, in which case
a warning is shown and those options are reset.
Parameters
----------
batch_mode
``True`` if extract is running in batch mode
input_locations
The discovered input locations within the input folder
args
The passed in command line arguments that may require amending
"""
if not batch_mode:
return
if not input_locations:
logger.error("Batch mode selected, but no valid files found in input location: '%s'. "
"Exiting.", args.input_dir)
sys.exit(1)
if args.alignments_path:
logger.warning("Custom alignments path not supported for batch mode. "
"Reverting to default.")
args.alignments_path = None
@classmethod
def _configure_torch(cls, compile_models: bool) -> None:
"""Set various Torch switches for inference optimization
Parameters
----------
compile_models
``True`` if model compilation has been requested
"""
torch.backends.cudnn.benchmark = True
torch.use_deterministic_algorithms(False)
torch.backends.cuda.matmul.allow_tf32 = True
torch.backends.cudnn.allow_tf32 = True
if not compile_models:
return
# pylint:disable=protected-access
torch._dynamo.config.cache_size_limit = 512
def _should_save_alignments(self, arguments: Namespace) -> bool:
""" Decide whether alignments should be saved from the given command line arguments and
output suitable information
Parameters
---------
arguments
The arguments generated from Faceswap's command line arguments
Returns
-------
``True`` if alignments should be saved
"""
if arguments.detector == arguments.aligner == "file" and (
arguments.masker is None and arguments.identity is None):
logger.debug("[Extract] Extracting directly from file. Not saving alignments")
return False
if arguments.detector == arguments.aligner == "file" and arguments.extract_every_n > 1:
logger.warning("Alignments loaded from file, EEN > 1 and additional plugins selected.")
logger.warning("The extracted faces will contain the additional plugin data, but an "
"updated Alignments File will not be saved.")
return False
if arguments.detector == arguments.aligner == "file":
logger.info("Alignments file will be updated with data from additional plugins")
return True
def _load_pipeline(self, arguments: Namespace) -> ExtractRunner: # noqa[C901]
""" Create the extraction pipeline and run profiling, if selected
Parameters
---------
arguments
The arguments generated from Faceswap's command line arguments
Returns
-------
The final runner, with input interfaces, from the pipeline
"""
retval = None
conf_file = arguments.config_file
profile = arguments.benchmark
try:
if arguments.detector != "file":
retval = Detect(arguments.detector,
rotation=arguments.rotate_images,
min_size=arguments.min_size,
max_size=arguments.max_size,
compile_model=arguments.compile,
config_file=conf_file)(retval, profile=profile)
if arguments.aligner != "file":
retval = Align(arguments.aligner,
re_feeds=arguments.re_feed,
re_align=arguments.re_align,
normalization=arguments.normalization,
filters=arguments.align_filters,
compile_model=arguments.compile,
config_file=conf_file)(retval, profile=profile)
if arguments.masker is not None:
for masker in arguments.masker:
retval = Mask(masker,
compile_model=arguments.compile,
config_file=conf_file)(retval, profile=profile)
if arguments.identity:
for idx, identity in enumerate(arguments.identity):
retval = Identity(identity,
self._face_filter.threshold,
compile_model=arguments.compile,
config_file=conf_file)(retval, profile=profile)
if self._face_filter.enabled and idx == 0:
# Add the first selected identity plugin
self._face_filter.add_identity_plugin(retval)
if retval is not None and profile:
Profiler(retval)()
retval = File()() if retval is None else retval
except Exception:
logger.debug("[Extract] Error during pipeline initialization")
if retval is not None:
retval.stop()
raise
logger.debug("[Extract] Pipeline output: %s", retval)
return retval
def process(self) -> None:
""" Run the extraction process """
try:
if self._face_filter.enabled:
self._face_filter.get_embeddings(self._pipeline)
self._output.start()
for batch in self._batches:
batch.loader.start(batch.alignments.data)
batch.loader.join()
if batch.loader.error_state.has_error:
batch.loader.error_state.re_raise()
self._output.join()
except Exception:
self._output.join()
self._pipeline.stop()
raise
class Loader: # pylint:disable=too-many-instance-attributes
""" Loads images/video frames from disks and puts to queue for feeding the extraction pipeline
Parameters
----------
pipeline
The final plugin in the extraction pipeline
input_path
Full path to a folder of images or a video file
input_is_file
``True`` if the input plugin to the pipeline is an alignments file (fsa or json) so
detected faces should be loaded from the file and passed into the pipeline
extract_every
The number of frames to extract from the source. 1 will extract every frame, 5 every 5th
frame etc
skip_existing_frames
``True`` if existing extracted frames should be skipped
skip_existing_faces
``True`` if frames with existing face detections should be skipped
is_final
``True`` if this loader is for the final batch being processed
"""
def __init__(self,
pipeline: ExtractRunner,
input_path: str,
input_is_file: bool,
extract_every: int,
skip_existing_frames: bool,
skip_existing_faces: bool,
is_final: bool) -> None:
logger.debug(parse_class_init(locals()))
self.location = input_path
"""Full path to the input location for the loader"""
self.existing_count = 0
"""The number of frames that pre-exist within the alignments file that will be skipped
because skip_existing/skip_existing_faces has been selected"""
self._input_is_file = input_is_file
self._pipeline = pipeline
self._is_final = is_final
self._extract_every = extract_every
self._skip_frames = skip_existing_frames
self._skip_faces = skip_existing_faces
self._images = ImagesLoader(input_path)
self._thread = FSThread(self._load, name="ExtractLoader")
self._alignments: dict[str, AlignmentsEntry] = {}
self._missing_count = 0
self._seen: set[str] = set()
self._ready = False
@property
def count(self) -> int:
"""The number of frames to be processed"""
# Wait until skip list has been processed before allowing another thread to call the count
while True:
if self._ready:
break
sleep(0.25)
continue
return self._images.process_count
@property
def is_video(self) -> bool:
"""``True`` if the input location is a video file, ``False`` for folder of images"""
return self._images.is_video
@property
def error_state(self) -> ErrorState:
"""The global FSThread error state object"""
return self._thread.error_state
def _set_skip_list(self) -> None:
""" Add the skip list to the image loader
Checks against `extract_every_n` and the existence of alignments data (can exist if
`skip_existing` or `skip_existing_faces` has been provided) and compiles a list of frame
indices that should not be processed, providing these to :class:`lib.image.ImagesLoader`.
"""
existing = list(self._alignments)
if self._extract_every == 1 and not existing:
logger.debug("[Extract.Loader] No frames to be skipped")
self._ready = True
return
skip_een = set(i for i in range(self._images.count) if i % self._extract_every != 0)
file_names = ([os.path.basename(f) for f in self._images.file_list]
if self._skip_frames or self._skip_faces else [])
skip_frames = set(i for i, f in enumerate(file_names)
if f in existing) if self._skip_frames else set()
skip_faces = (
set(i for i, f in enumerate(file_names)
if f in self._alignments and self._alignments[f].faces)
if self._skip_faces else set()
)
skip_exist = skip_frames.union(skip_faces)
if self._extract_every > 1:
logger.info("Skipping %s frames of %s for extract every %s",
len(skip_een), self._images.count, self._extract_every)
if skip_exist:
self.existing_count = len(skip_exist.difference(skip_een))
logger.info("Skipping %s frames of %s for skip existing frames/faces",
self.existing_count, self._images.count - len(skip_een))
skip = list(skip_exist.union(skip_een))
logger.debug("[Extract.Loader] Total skip count: %s", len(skip))
self._images.add_skip_list(skip)
self._ready = True
def _get_detected_faces(self, file_path: str) -> list[DetectedFace] | None:
"""When importing data, obtain the existing detected face objects for passing through the
pipeline
Parameters
----------
file_path
The full path to the image being loaded
Returns
-------
list[DetectedFace] | None
The imported detected face objects or ``None`` if data is not being imported
"""
if not self._input_is_file:
return None
fname = os.path.basename(file_path)
self._seen.add(fname)
if fname not in self._alignments:
self._missing_count += 1
logger.verbose( # type:ignore[attr-defined]
"Adding frame with no detections as does not exist in import file: '%s'", fname)
return []
retval = [DetectedFace().from_alignment(a) for a in self._alignments[fname].faces]
logger.trace( # type:ignore[attr-defined]
"[Extract.Loader] importing %s faces for file '%s'", len(retval), fname)
return retval
def _finalize(self) -> None:
"""Actions to run when the loader is exhausted"""
if self._is_final:
self._pipeline.stop()
if self._missing_count > 0:
logger.warning("%s images did not exist in the import file. Run in verbose mode to "
"see which files have been added with no detected faces.",
self._missing_count)
processed_files = set(self._images.processed_file_list)
if self._input_is_file and len(self._seen) != len(processed_files):
logger.warning("%s images exist in the import file but do not exist on disk. Run in "
"verbose mode to see which files are missing.",
len(processed_files) - len(self._seen))
logger.verbose( # type:ignore[attr-defined]
"Files in import file that do not exist on disk: %s",
list(sorted(processed_files.difference(self._seen))))
def _load(self) -> None:
""" Load images from disk and pass to a queue for the extraction pipeline """
logger.debug("[Extract.Loader] start")
for filename_image in self._images.load():
filename, image = filename_image[:2]
faces = self._get_detected_faces(filename)
self._pipeline.put(filename, image, source=self.location, detected_faces=faces)
if self.error_state.has_error:
logger.debug("[Extract.Loader] Thread error OUT detected in worker thread")
return
self._finalize()
logger.debug("[Extract.Loader] end")
def start(self, alignments: dict[str, AlignmentsEntry]) -> None:
""" Set the skip list and start loading images from disk
Parameters
----------
alignments
Dictionary of existing alignments data for use when importing or skipping existing data
"""
self._alignments = alignments
self._set_skip_list()
logger.debug("[Extract.Loader] start thread")
if isinstance(self._pipeline.handler, File):
self._pipeline.register_external_error_state(self._thread.error_state)
self._thread.start()
def join(self) -> None:
""" Join the image loading thread and monitor for keyboard interrupts"""
logger.debug("[Extract.Loader] join thread")
while self._thread.is_alive():
try:
self._thread.join(timeout=0.2)
except KeyboardInterrupt:
logger.debug("Terminate signal received. Stopping...")
raise
logger.debug("[Extract.Loader] joined thread")
class DebugLandmarks():
"""Draw debug landmarks on face output.
Parameters
----------
size
The size of the extracted face image
"""
def __init__(self, size: int) -> None:
logger.debug(parse_class_init(locals()))
self._size = size
self._face_size = get_sub_crop_size("head", "face", size)
self._legacy_size = get_sub_crop_size("head", "legacy", size)
self._camera_matrix = get_camera_matrix()
self._mean_face = MEAN_FACE[LandmarkType.LM_2D_51][None]
self._face_expansion = 1.0 - EXTRACT_RATIOS["face"]
self._font = cv2.FONT_HERSHEY_SIMPLEX
self._font_scale = size / 512
self._font_pad = size // 64
def _border_text(self,
image: np.ndarray,
text: str,
color: tuple[int, int, int],
position: tuple[int, int]) -> None:
"""Create text on an image with a black border
Parameters
----------
image
The image to put bordered text on to
text
The text to place the image
color
The color of the text
position
The (x, y) co-ordinates to place the text
"""
thickness = 2
for idx in range(2):
text_color = (0, 0, 0) if idx == 0 else color
cv2.putText(image,
text,
position,
self._font,
self._font_scale,
text_color,
thickness,
lineType=cv2.LINE_AA)
thickness //= 2
def _annotate_face_box(self,
face: npt.NDArray[np.uint8],
offset_head: npt.NDArray[np.float32],
offset_face: npt.NDArray[np.float32],
face_size) -> None:
"""Annotate the face extract box and print the original size in pixels
Parameters
----------
face
The face image to annotate
offset_head
The (X, Y) offset for the head centered extract
offset_face
The (X, Y) offset for the face centered extract
face_size
The size of the face box in the original frame
"""
color = (0, 255, 0)
center = get_adjusted_center(self._size, offset_head, offset_face, "head", 0)
padding = self._face_size // 2
roi = np.array([center - padding, center + padding]).tolist()
cv2.rectangle(face, roi[0], roi[1], color, 1)
# Size in top right corner
text_img = face.copy()
text = f"{face_size}px"
text_size = cv2.getTextSize(text, self._font, self._font_scale, 1)[0]
pos_x = roi[1][0] - (text_size[0] + self._font_pad)
pos_y = roi[0][1] + text_size[1] + self._font_pad
self._border_text(text_img, text, color, (pos_x, pos_y))
cv2.addWeighted(text_img, 0.75, face, 0.25, 0, face)
def _print_stats(self,
face: npt.NDArray[np.uint8],
distance: float,
pitch: float,
roll: float,
yaw: float) -> None:
"""Print various metrics on the output face images
Parameters
----------
face
The face image to annotate
distance
The distance of the face from a 'mean' face
pitch
The pitch of the face in degrees
roll
The roll of the face in degrees
yaw
The yaw of the face in degrees
"""
text_image = face.copy()
texts = [f"pitch: {pitch:.2f}",
f"yaw: {yaw:.2f}",
f"roll: {roll: .2f}",
f"distance: {distance:.2f}"]
colors = [(255, 0, 0), (0, 0, 255), (0, 255, 0), (255, 255, 255)]
text_sizes = [cv2.getTextSize(text, self._font, self._font_scale, 1)[0] for text in texts]
final_y = self._size - text_sizes[-1][1]
pos_y = [(size[1] + self._font_pad) * (idx + 1)
for idx, size in enumerate(text_sizes)][:-1] + [final_y]
pos_x = self._font_pad
for idx, text in enumerate(texts):
self._border_text(text_image, text, colors[idx], (pos_x, pos_y[idx]))
# Apply text to face
cv2.addWeighted(text_image, 0.75, face, 0.25, 0, face)
def __call__(self, # pylint:disable=too-many-locals
faces: npt.NDArray[np.uint8],
matrices: npt.NDArray[np.float32],
media: FrameFaces) -> None:
"""Draw debug annotations on extracted face images
Parameters
----------
faces
The aligned face images that are to be saved to disk
matrices
The adjustment matrices for transforming from frame space to face space
media
The corresponding FrameFaces media object for the faces
"""
if not np.any(faces):
return
landmarks = batch_transform(
matrices, T.cast("npt.NDArray[np.float32]", media.landmarks)).astype("int32")
aligned = media.aligned
norm_mats = aligned.matrices[:, :2, 0]
sizes = np.rint(1.0 / (self._face_expansion *
np.hypot(norm_mats[:, 0], norm_mats[:, 1]))).astype(np.int32)
dists = np.abs(aligned.landmarks_normalized[:, 17:] -
self._mean_face).mean(axis=(1, 2))
pry = (Batch3D.pitch(aligned.rotation),
Batch3D.roll(aligned.rotation),
Batch3D.yaw(aligned.rotation))
for idx, (face, lms) in enumerate(zip(faces, landmarks)):
# Landmarks
for (pos_x, pos_y) in lms:
cv2.circle(face, (pos_x, pos_y), 1, (0, 255, 255), -1)
# Pose
center = (self._size // 2, self._size // 2)
xyz = get_xyz_2d(aligned.rotation[idx],
aligned.translation[idx],
self._camera_matrix) - aligned.offsets_head[idx]
points = (xyz * self._size).astype("int32")
cv2.line(face, center, tuple(points[1]), (0, 255, 0), 1)
cv2.line(face, center, tuple(points[0]), (255, 0, 0), 1)
cv2.line(face, center, tuple(points[2]), (0, 0, 255), 1)
# Face centering
self._annotate_face_box(face,
aligned.offsets_head[idx],
aligned.offsets_face[idx],
int(sizes[idx]))
# Legacy centering
center_a = get_adjusted_center(self._size,
aligned.offsets_head[idx],
aligned.offsets_legacy[idx],
"head",
0)
padding = self._legacy_size // 2
roi = np.array([center_a - padding, center_a + padding]).tolist()
cv2.rectangle(face, roi[0], roi[1], (0, 0, 255), 1)
# Pitch/roll/yaw/distance
self._print_stats(face,
float(dists[idx]),
float(pry[0][idx]),
float(pry[1][idx]),
float(pry[2][idx]))
class Output: # pylint:disable=too-many-instance-attributes
""" Handles output processing and saving of extracted faces
Parameters
----------
pipeline
The output runner from the extraction pipeline
output_folder
The full path to the output folder to save extracted faces. ``None`` to not save faces
size
The size to save extracted faces at
min_scale
The minimum percentage of the output size that should be accepted for outputting a face
to disk
batches
The information about each batch that is to be processed
save_interval
How often to save the alignments file
debug_landmarks
``True`` to annotate the output images with debug data
"""
def __init__(self,
pipeline: ExtractRunner,
output_folder: str | None,
size: int,
min_scale: int,
batches: list[BatchInfo],
save_interval: int,
debug_landmarks: bool) -> None:
logger.debug(parse_class_init(locals()))
self._pipeline = pipeline
self._size = size
self._batches = batches
self._save_interval = save_interval
self._min_size = self._get_min_size(size, min_scale)
self._saver: None | ImagesSaver = None
self._outputs = self._get_outputs(output_folder)
self._thread = FSThread(self._process, name="ExtractOutput")
self._debug = DebugLandmarks(size) if debug_landmarks else None
self._counts = {"verify": False, "faces": 0, "scale_skip": 0}
self._align = {"padding": round((size * EXTRACT_RATIOS["head"]) / 2),
"padding_thumbnail": round((96 * EXTRACT_RATIOS["head"]) / 2),
"empty_faces": np.empty((0, size, size, 3), dtype=np.uint8)}
@classmethod
def _get_min_size(cls, extract_size: int, min_scale: int) -> int:
""" Obtain the minimum size that a face has been resized from to be included as a valid
extract.
Parameters
----------
extract_size
The requested size of the extracted images
min_scale
The percentage amount that has been supplied for valid faces (as a percentage of
extract size)
Returns
-------
The minimum size, in pixels, that a face is resized from to be considered valid
"""
retval = 0 if min_scale == 0 else max(4, int(extract_size * (min_scale / 100.)))
logger.debug("[Extract.Output] Extract size: %s, min percentage size: %s, min_size: %s",
extract_size, min_scale, retval)
return retval
def _get_outputs(self, output_folder: str | None) -> list[str | None]:
""" Obtain the locations to save the output for each batch input location
Parameters
----------
output_folder
The full path to the output folder to save extracted faces. ``None`` to not save faces
Returns
-------
The output locations for each input batch. ``None`` if faces are not to be saved
"""
num_batches = len(self._batches)
retval: list[str | None]
if not output_folder:
logger.debug("[Extract.Output] No save location selected")
return [None for _ in range(num_batches)]
out_folder = get_folder(output_folder)
if num_batches == 1:
logger.debug("[Extract.Output] Single save location: '%s'", out_folder)
return [out_folder]
retval = [os.path.join(out_folder,
os.path.splitext(os.path.basename(b.loader.location))[0])
for b in self._batches]
logger.debug("[Extract.Output] Save locations: %s", retval)
return retval
def _should_output(self, matrices: npt.NDArray[np.float32]) -> npt.NDArray[np.bool_]:
"""Test which of the faces should be saved based on the given minimum scale option
Parameters
----------
matrices
The normalized aligned matrices to check for original face size
Returns
-------
Mask array containing ``True`` for each face that should be output.
"""
if self._min_size <= 0:
return np.fromiter((True for _ in range(len(matrices))), dtype=bool)
linear = matrices[:, :2, 0]
sizes = 1.0 / ((1.0 - EXTRACT_RATIOS["face"]) * np.hypot(linear[:, 0], linear[:, 1]))
return sizes >= self._min_size
def _save_faces(self, # pylint:disable=too-many-locals
faces: npt.NDArray[np.uint8],
matrices: npt.NDArray[np.float32],
basename: str,
meta: list[PNGAlignments],
frame_size: tuple[int, int],
alignments_version: float,
is_video: bool) -> None:
"""Encode the aligned faces with PNG Header information and save to disk
Parameters
----------
faces
The correctly sized and aligned faces to save for a frame
matrices
The normalized aligned affine matrices for the faces
basename
The base filename (without full path) of the original frame
meta
The meta data to add to each of the PNG headers
frame_size
The (height, width) of the original frame
alignments_version
The current alignments file version
is_video
``True`` if the input is a video otherwise ``False``
"""
if self._saver is None:
return
split_name = os.path.splitext(basename)[0]
for idx, (face, data, save) in enumerate(zip(faces, meta, self._should_output(matrices))):
if not save:
self._counts["scale_skip"] += 1
continue
img_name = f"{split_name}_{idx}.png"
header = PNGHeader(alignments=data,
source=PNGSource(alignments_version=alignments_version,
original_filename=img_name,
face_index=idx,
source_filename=basename,
source_is_video=is_video,
source_frame_dims=frame_size))
img = encode_image(face, ".png", metadata=asdict(header))
self._saver.save(img_name, img)
def _get_faces_and_thumbs(self, media: FrameFaces
) -> tuple[npt.NDArray[np.uint8], list[npt.NDArray[np.uint8]]]:
"""Obtain the aligned faces and jpeg thumbnails from the given media object
Parameters
----------
media
The FrameFaces object output from the extraction pipeline
Returns
-------
faces
The (N, size, size, 3) aligned face images from the media object
thumbnails
The (N, 96, 96, 3) jpeg thumbnails for the media object
"""
if not media:
return (T.cast("npt.NDArray[np.uint8]", self._align["empty_faces"]), [])
image_ids = np.fromiter((0 for _ in range(len(media))), dtype=np.int32)
if self._saver is None:
faces = np.empty((0, self._size, self._size, 3), dtype=np.uint8)
mats = batch_adjust_matrices(media.aligned.matrices_head,
96,
T.cast(int, self._align["padding_thumbnail"]))
thumbs = batch_align([media.image], image_ids, mats, 96)
else:
mats = batch_adjust_matrices(media.aligned.matrices_head,
self._size,
T.cast(int, self._align["padding"]))
faces = batch_align([media.image],
image_ids,
mats,
self._size,
fast_upscale=False)
thumbs = batch_resize(faces, 96)
if self._debug is not None:
self._debug(faces, mats, media)
thumbnails = [cv2.imencode(".jpg", t, [cv2.IMWRITE_JPEG_QUALITY, 60])[1]
for t in thumbs]
return faces, thumbnails
def _process_faces(self, media: FrameFaces, alignments: Alignments, is_video: bool) -> None:
""" Process the detected face objects into aligned faces, generate the thumbnails and run
any post process actions
Parameters
----------
media
The FrameFaces object output from the extraction pipeline
alignments
The alignments object that is to contain these faces
is_video
``True`` if the input is a video otherwise ``False``
"""
basename = os.path.basename(media.filename)
faces, thumbnails = self._get_faces_and_thumbs(media)
media.remove_image() # Spare the RAM
meta = frame_faces_to_alignment(media)
self._save_faces(faces,
media.aligned.matrices,
basename,
meta,
media.image_size,
alignments.version,
is_video)
alignments_faces = [FileAlignments(**aln.__dict__, thumb=thumb)
for aln, thumb in zip(meta, thumbnails)]
alignments.data[basename] = AlignmentsEntry(faces=alignments_faces)
faces_count = len(media)
if faces_count == 0:
logger.verbose("No faces were detected in image: %s", basename) # type: ignore
if not self._counts["verify"] and faces_count > 1:
self._counts["verify"] = True
self._counts["faces"] += faces_count
def _set_saver(self, output: str | None) -> None:
"""Close the currently active saver and set the next :attr:`_saver` for the given output
Parameters
----------
output
The full path to the next output location
"""
if self._saver is not None:
self._saver.close()
if output is None:
self._saver = None
else:
self._saver = ImagesSaver(get_folder(output), as_bytes=True)
logger.debug("[Extract.Output] Set image saver to location: %s",
repr(self._saver if self._saver is None else self._saver.location))
def _finalize_batch(self, batch: BatchInfo, batch_index: int) -> None:
""" Actions to perform when an input batch has finished processing.
Parameters
----------
batch
The information about the batch that has finished processing
batch_index
The index of the batch in :attr:`_self._batches`
"""
logger.debug("[Extract.Output] Finalizing batch: %s", batch)
if batch.alignments.save_alignments:
if not self._save_interval:
batch.alignments.backup()
batch.alignments.save()
count = batch.loader.count - batch.loader.existing_count
if self._counts["scale_skip"] > 0:
logger.info("%s faces not output as they are below the minimum size of %spx. These "
"still exist in the alignments file.",
self._counts["scale_skip"], self._min_size)
finalize(count, T.cast(int, self._counts["faces"]), T.cast(bool, self._counts["verify"]))
self._counts["verify"] = False
output = None if batch_index == len(self._outputs) - 1 else self._outputs[batch_index + 1]
self._set_saver(output)
self._counts["faces"] = 0
self._counts["scale_skip"] = 0
del batch.alignments
def _process(self) -> None: # noqa[C901]
""" Process the output from the extraction pipeline within a thread """
logger.debug("[Extract.Output] start")
total_batches = len(self._batches)
self._set_saver(self._outputs[0])
if self._saver is not None and self._min_size > 0:
logger.info("Only outputting faces that have been resized from a minimum resolution "
"of %spx", self._min_size)
for batch_idx, batch in enumerate(self._batches):
msg = f" job {batch_idx + 1} of {total_batches}" if total_batches > 1 else ""
logger.info("Processing%s: '%s'", msg, batch.loader.location)
if self._saver is not None:
logger.info("Faces output: '%s'", self._saver.location)
has_started = False
save_interval = 0 if not batch.alignments.save_alignments else self._save_interval
with tqdm(desc="Extracting faces",
total=batch.loader.count,
leave=True,
smoothing=0) as prog_bar:
if batch_idx > 0: # Update for batch picked up at end of previous batch
prog_bar.update(1)
for idx, media in enumerate(self._pipeline):
if not has_started:
prog_bar.reset() # Delay before first output, reset timer for better it/s
has_started = True
if media.source != batch.loader.location:
self._finalize_batch(batch, batch_idx)
next_batch = self._batches[batch_idx + 1]
self._process_faces(media, next_batch.alignments,
next_batch.loader.is_video)
break
self._process_faces(media, batch.alignments, batch.loader.is_video)
if save_interval and (idx + 1) % save_interval == 0:
batch.alignments.save()
if prog_bar.n + 1 > prog_bar.total:
# Don't switch to unknown mode when frame count is under
prog_bar.total += 1
prog_bar.update(1)
if self._thread.error_state.has_error:
logger.debug("[Extract.Output] Thread error detected in worker thread")
return
self._finalize_batch(self._batches[-1], len(self._batches) - 1)
logger.debug("[Extract.Output] end")
def start(self) -> None:
""" Start the output thread """
logger.debug("[Extract.Output] start thread")
self._thread.start()
def join(self) -> None:
""" Join the output thread """
logger.debug("[Extract.Output] join thread")
self._thread.join()
logger.debug("[Extract.Output] joined thread")
__all__ = get_module_objects(__name__)