Add Mask importing to the mask tool (#1376)

* lib.align.alignments: expose count_faces_in_frame
* tools.mask: refactor and fix frame output to display all masks in a single frame
* tools.mask: add import mask process
* manual tool: Remove NN masks on landmark edit
This commit is contained in:
torzdf 2024-03-12 13:17:35 +00:00 committed by GitHub
parent d1dfce8a13
commit 7a16f753cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 2333 additions and 831 deletions

View File

@ -426,7 +426,7 @@ class Alignments():
frame_data = self._data.get(frame_name, T.cast(AlignmentDict, {}))
return frame_data.get("faces", T.cast(list[AlignmentFileDict], []))
def _count_faces_in_frame(self, frame_name: str) -> int:
def count_faces_in_frame(self, frame_name: str) -> int:
""" Return number of faces that appear within :attr:`data` for the given frame_name.
Parameters
@ -464,7 +464,7 @@ class Alignments():
"""
logger.debug("Deleting face %s for frame_name '%s'", face_index, frame_name)
face_index = int(face_index)
if face_index + 1 > self._count_faces_in_frame(frame_name):
if face_index + 1 > self.count_faces_in_frame(frame_name):
logger.debug("No face to delete: (frame_name: '%s', face_index %s)",
frame_name, face_index)
return False
@ -493,7 +493,7 @@ class Alignments():
if frame_name not in self._data:
self._data[frame_name] = {"faces": [], "video_meta": {}}
self._data[frame_name]["faces"].append(face)
retval = self._count_faces_in_frame(frame_name) - 1
retval = self.count_faces_in_frame(frame_name) - 1
logger.debug("Returning new face index: %s", retval)
return retval
@ -542,6 +542,18 @@ class Alignments():
source_frame, face_idx)
del frame_data["faces"][face_idx]
def update_from_dict(self, data: dict[str, AlignmentDict]) -> None:
""" Replace all alignments with the contents of the given dictionary
Parameters
----------
data: dict[str, AlignmentDict]
The alignments, in correctly formatted dictionary form, to be populated into this
:class:`Alignments`
"""
logger.debug("Populating alignments with %s entries", len(data))
self._data = data
# << GENERATORS >> #
def yield_faces(self) -> Generator[tuple[str, list[AlignmentFileDict], int, str], None, None]:
""" Generator to obtain all faces with meta information from :attr:`data`. The results

View File

@ -6,8 +6,8 @@ msgid ""
msgstr ""
"Project-Id-Version: faceswap.spanish\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-02-20 23:42+0000\n"
"PO-Revision-Date: 2023-02-20 23:45+0000\n"
"POT-Creation-Date: 2024-03-11 23:45+0000\n"
"PO-Revision-Date: 2024-03-11 23:50+0000\n"
"Last-Translator: \n"
"Language-Team: tokafondo\n"
"Language: es_ES\n"
@ -16,30 +16,36 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"Generated-By: pygettext.py 1.5\n"
"X-Generator: Poedit 3.0.1\n"
"X-Generator: Poedit 3.4.2\n"
#: tools/mask/cli.py:15
msgid "This command lets you generate masks for existing alignments."
msgid ""
"This tool allows you to generate, import, export or preview masks for "
"existing alignments."
msgstr ""
"Este comando permite generar máscaras para las alineaciones existentes."
"Esta herramienta le permite generar, importar, exportar o obtener una vista "
"previa de máscaras para alineaciones existentes.\n"
"Genere, importe, exporte o obtenga una vista previa de máscaras para "
"archivos de alineaciones existentes."
#: tools/mask/cli.py:24
#: tools/mask/cli.py:25
msgid ""
"Mask tool\n"
"Generate masks for existing alignments files."
"Generate, import, export or preview masks for existing alignments files."
msgstr ""
"Herramienta de máscara\n"
"Genera máscaras para los archivos de alineación existentes."
"Genere, importe, exporte o obtenga una vista previa de máscaras para "
"archivos de alineaciones existentes."
#: tools/mask/cli.py:33 tools/mask/cli.py:44 tools/mask/cli.py:54
#: tools/mask/cli.py:64
#: tools/mask/cli.py:35 tools/mask/cli.py:47 tools/mask/cli.py:58
#: tools/mask/cli.py:69
msgid "data"
msgstr "datos"
#: tools/mask/cli.py:36
#: tools/mask/cli.py:39
msgid ""
"Full path to the alignments file to add the mask to if not at the default "
"location. NB: If the input-type is faces and you wish to update the "
"Full path to the alignments file that contains the masks if not at the "
"default location. NB: If the input-type is faces and you wish to update the "
"corresponding alignments file, then you must provide a value here as the "
"location cannot be automatically detected."
msgstr ""
@ -48,13 +54,13 @@ msgstr ""
"actualizar el archivo de alineaciones correspondiente, debe proporcionar un "
"valor aquí ya que la ubicación no se puede detectar automáticamente."
#: tools/mask/cli.py:47
#: tools/mask/cli.py:51
msgid "Directory containing extracted faces, source frames, or a video file."
msgstr ""
"Directorio que contiene las caras extraídas, los fotogramas de origen o un "
"archivo de vídeo."
#: tools/mask/cli.py:56
#: tools/mask/cli.py:61
msgid ""
"R|Whether the `input` is a folder of faces or a folder frames/video\n"
"L|faces: The input is a folder containing extracted faces.\n"
@ -64,7 +70,7 @@ msgstr ""
"L|faces: La entrada es una carpeta que contiene caras extraídas.\n"
"L|frames: La entrada es una carpeta que contiene fotogramas o es un vídeo"
#: tools/mask/cli.py:65
#: tools/mask/cli.py:71
msgid ""
"R|Run the mask tool on multiple sources. If selected then the other options "
"should be set as follows:\n"
@ -89,11 +95,11 @@ msgstr ""
"con 'caras' como tipo de entrada, solo se actualizará el encabezado PNG "
"dentro de las caras extraídas."
#: tools/mask/cli.py:81 tools/mask/cli.py:113
#: tools/mask/cli.py:87 tools/mask/cli.py:119
msgid "process"
msgstr "proceso"
#: tools/mask/cli.py:82
#: tools/mask/cli.py:89
msgid ""
"R|Masker to use.\n"
"L|bisenet-fp: Relatively lightweight NN based mask that provides more "
@ -118,9 +124,8 @@ msgid ""
"some facial obstructions (hands and eyeglasses). Profile faces may result in "
"sub-par performance.\n"
"L|unet-dfl: Mask designed to provide smart segmentation of mostly frontal "
"faces. The mask model has been trained by community members and will need "
"testing for further description. Profile faces may result in sub-par "
"performance."
"faces. The mask model has been trained by community members. Profile faces "
"may result in sub-par performance."
msgstr ""
"R|Máscara a utilizar.\n"
"L|bisenet-fp: Máscara relativamente ligera basada en NN que proporciona un "
@ -152,32 +157,111 @@ msgstr ""
"descripción. Los rostros de perfil pueden dar lugar a un rendimiento "
"inferior."
#: tools/mask/cli.py:114
#: tools/mask/cli.py:121
msgid ""
"R|Whether to update all masks in the alignments files, only those faces that "
"do not already have a mask of the given `mask type` or just to output the "
"masks to the `output` location.\n"
"L|all: Update the mask for all faces in the alignments file.\n"
"R|The Mask tool process to perform.\n"
"L|all: Update the mask for all faces in the alignments file for the selected "
"'masker'.\n"
"L|missing: Create a mask for all faces in the alignments file where a mask "
"does not previously exist.\n"
"L|output: Don't update the masks, just output them for review in the given "
"output folder."
"does not previously exist for the selected 'masker'.\n"
"L|output: Don't update the masks, just output the selected 'masker' for "
"review/editing in external tools to the given output folder.\n"
"L|import: Import masks that have been edited outside of faceswap into the "
"alignments file. Note: 'custom' must be the selected 'masker' and the masks "
"must be in the same format as the 'input-type' (frames or faces)"
msgstr ""
"R|Si se actualizan todas las máscaras en los archivos de alineación, sólo "
"aquellas caras que no tienen ya una máscara del \"tipo de máscara\" dado o "
"sólo se envían las máscaras a la ubicación \"de salida\".\n"
"L|all: Actualiza la máscara de todas las caras del archivo de alineación.\n"
"L|missing: Crea una máscara para todas las caras del fichero de alineaciones "
"en las que no existe una máscara previamente.\n"
"L|output: No actualiza las máscaras, sólo las emite para su revisión en la "
"carpeta de salida dada."
"R|Процесс инструмента «Маска», который необходимо выполнить.\n"
"L|all: обновить маску для всех лиц в файле выравниваний для выбранного "
"«masker».\n"
"L|missing: создать маску для всех граней в файле выравниваний, где маска "
"ранее не существовала для выбранного «masker».\n"
"L|output: не обновляйте маски, просто выведите выбранный «masker» для "
"просмотра/редактирования во внешних инструментах в данную выходную папку.\n"
"L|import: импортируйте маски, которые были отредактированы вне Facewap, в "
"файл выравниваний. Примечание. «custom» должен быть выбранным «masker», а "
"маски должны быть в том же формате, что и «input-type» (frames или faces)."
#: tools/mask/cli.py:127 tools/mask/cli.py:134 tools/mask/cli.py:147
#: tools/mask/cli.py:160 tools/mask/cli.py:169
#: tools/mask/cli.py:135 tools/mask/cli.py:154 tools/mask/cli.py:174
msgid "import"
msgstr "importar"
#: tools/mask/cli.py:137
msgid ""
"R|Import only. The path to the folder that contains masks to be imported.\n"
"L|How the masks are provided is not important, but they will be stored, "
"internally, as 8-bit grayscale images.\n"
"L|If the input are images, then the masks must be named exactly the same as "
"input frames/faces (excluding the file extension).\n"
"L|If the input is a video file, then the filename of the masks is not "
"important but should contain the frame number at the end of the filename "
"(but before the file extension). The frame number can be separated from the "
"rest of the filename by any non-numeric character and can be padded by any "
"number of zeros. The frame number must correspond correctly to the frame "
"number in the original video (starting from frame 1)."
msgstr ""
"R|Sólo importar. La ruta a la carpeta que contiene las máscaras que se "
"importarán.\n"
"L|Cómo se proporcionan las máscaras no es importante, pero se almacenarán "
"internamente como imágenes en escala de grises de 8 bits.\n"
"L|Si la entrada son imágenes, entonces las máscaras deben tener el mismo "
"nombre que los cuadros/caras de entrada (excluyendo la extensión del "
"archivo).\n"
"L|Si la entrada es un archivo de vídeo, entonces el nombre del archivo de "
"las máscaras no es importante pero debe contener el número de fotograma al "
"final del nombre del archivo (pero antes de la extensión del archivo). El "
"número de fotograma se puede separar del resto del nombre del archivo "
"mediante cualquier carácter no numérico y se puede rellenar con cualquier "
"número de ceros. El número de fotograma debe corresponder correctamente al "
"número de fotograma del vídeo original (a partir del fotograma 1)."
#: tools/mask/cli.py:156
msgid ""
"R|Import only. The centering to use when importing masks. Note: For any job "
"other than 'import' this option is ignored as mask centering is handled "
"internally.\n"
"L|face: Centers the mask on the center of the face, adjusting for pitch and "
"yaw. Outside of requirements for full head masking/training, this is likely "
"to be the best choice.\n"
"L|head: Centers the mask on the center of the head, adjusting for pitch and "
"yaw. Note: You should only select head centering if you intend to include "
"the full head (including hair) within the mask and are looking to train a "
"full head model.\n"
"L|legacy: The 'original' extraction technique. Centers the mask near the of "
"the nose with and crops closely to the face. Can result in the edges of the "
"mask appearing outside of the training area."
msgstr ""
"R|Sólo importar. El centrado a utilizar al importar máscaras. Nota: Para "
"cualquier trabajo que no sea \"importar\", esta opción se ignora ya que el "
"centrado de la máscara se maneja internamente.\n"
"L|cara: centra la máscara en el centro de la cara, ajustando el tono y la "
"orientación. Aparte de los requisitos para el entrenamiento/enmascaramiento "
"de cabeza completa, esta probablemente sea la mejor opción.\n"
"L|head: centra la máscara en el centro de la cabeza, ajustando el cabeceo y "
"la guiñada. Nota: Sólo debe seleccionar el centrado de la cabeza si desea "
"incluir la cabeza completa (incluido el cabello) dentro de la máscara y "
"desea entrenar un modelo de cabeza completa.\n"
"L|legacy: La técnica de extracción 'original'. Centra la máscara cerca de la "
"nariz y la recorta cerca de la cara. Puede provocar que los bordes de la "
"máscara aparezcan fuera del área de entrenamiento."
#: tools/mask/cli.py:179
msgid ""
"Import only. The size, in pixels to internally store the mask at.\n"
"The default is 128 which is fine for nearly all usecases. Larger sizes will "
"result in larger alignments files and longer processing."
msgstr ""
"Sólo importar. El tamaño, en píxeles, para almacenar internamente la "
"máscara.\n"
"El valor predeterminado es 128, que está bien para casi todos los casos de "
"uso. Los tamaños más grandes darán como resultado archivos de alineaciones "
"más grandes y un procesamiento más largo."
#: tools/mask/cli.py:187 tools/mask/cli.py:195 tools/mask/cli.py:209
#: tools/mask/cli.py:223 tools/mask/cli.py:233
msgid "output"
msgstr "salida"
#: tools/mask/cli.py:128
#: tools/mask/cli.py:189
msgid ""
"Optional output location. If provided, a preview of the masks created will "
"be output in the given folder."
@ -185,7 +269,7 @@ msgstr ""
"Ubicación de salida opcional. Si se proporciona, se obtendrá una vista "
"previa de las máscaras creadas en la carpeta indicada."
#: tools/mask/cli.py:138
#: tools/mask/cli.py:200
msgid ""
"Apply gaussian blur to the mask output. Has the effect of smoothing the "
"edges of the mask giving less of a hard edge. the size is in pixels. This "
@ -198,7 +282,7 @@ msgstr ""
"redondeará al siguiente número impar. NB: Sólo afecta a la vista previa de "
"salida. Si se ajusta a 0, se desactiva"
#: tools/mask/cli.py:151
#: tools/mask/cli.py:214
msgid ""
"Helps reduce 'blotchiness' on some masks by making light shades white and "
"dark shades black. Higher values will impact more of the mask. NB: Only "
@ -209,7 +293,7 @@ msgstr ""
"más a la máscara. NB: Sólo afecta a la vista previa de salida. Si se ajusta "
"a 0, se desactiva"
#: tools/mask/cli.py:161
#: tools/mask/cli.py:225
msgid ""
"R|How to format the output when processing is set to 'output'.\n"
"L|combined: The image contains the face/frame, face mask and masked face.\n"
@ -224,7 +308,7 @@ msgstr ""
"enmascarada.\n"
"L|mask: Sólo emite la máscara como una imagen de un solo canal."
#: tools/mask/cli.py:170
#: tools/mask/cli.py:235
msgid ""
"R|Whether to output the whole frame or only the face box when using output "
"processing. Only has an effect when using frames as input."
@ -233,6 +317,26 @@ msgstr ""
"el cuadro de la cara cuando se utiliza el procesamiento de salida. Sólo "
"tiene efecto cuando se utilizan cuadros como entrada."
#~ msgid ""
#~ "R|Whether to update all masks in the alignments files, only those faces "
#~ "that do not already have a mask of the given `mask type` or just to "
#~ "output the masks to the `output` location.\n"
#~ "L|all: Update the mask for all faces in the alignments file.\n"
#~ "L|missing: Create a mask for all faces in the alignments file where a "
#~ "mask does not previously exist.\n"
#~ "L|output: Don't update the masks, just output them for review in the "
#~ "given output folder."
#~ msgstr ""
#~ "R|Si se actualizan todas las máscaras en los archivos de alineación, sólo "
#~ "aquellas caras que no tienen ya una máscara del \"tipo de máscara\" dado "
#~ "o sólo se envían las máscaras a la ubicación \"de salida\".\n"
#~ "L|all: Actualiza la máscara de todas las caras del archivo de "
#~ "alineación.\n"
#~ "L|missing: Crea una máscara para todas las caras del fichero de "
#~ "alineaciones en las que no existe una máscara previamente.\n"
#~ "L|output: No actualiza las máscaras, sólo las emite para su revisión en "
#~ "la carpeta de salida dada."
#~ msgid ""
#~ "Full path to the alignments file to add the mask to. NB: if the mask "
#~ "already exists in the alignments file it will be overwritten."

View File

@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-02-20 23:42+0000\n"
"PO-Revision-Date: 2023-02-20 23:44+0000\n"
"POT-Creation-Date: 2024-03-11 23:45+0000\n"
"PO-Revision-Date: 2024-03-11 23:50+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: ko_KR\n"
@ -16,29 +16,34 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Generator: Poedit 3.0.1\n"
"X-Generator: Poedit 3.4.2\n"
#: tools/mask/cli.py:15
msgid "This command lets you generate masks for existing alignments."
msgstr "이 명령어는 이미 존재하는 alignments로부터 마스크를 생성하게 해줍니다."
msgid ""
"This tool allows you to generate, import, export or preview masks for "
"existing alignments."
msgstr ""
"이 도구를 사용하면 기존 정렬에 대한 마스크를 생성, 가져오기, 내보내기 또는 미"
"리 볼 수 있습니다."
#: tools/mask/cli.py:24
#: tools/mask/cli.py:25
msgid ""
"Mask tool\n"
"Generate masks for existing alignments files."
"Generate, import, export or preview masks for existing alignments files."
msgstr ""
"마스크 도구\n"
"존재하는 alignments 파일들로부터 마스크를 생성합니다."
"기존 alignments 파일에 대한 마스크를 생성, 가져오기, 내보내기 또는 미리 봅니"
"다."
#: tools/mask/cli.py:33 tools/mask/cli.py:44 tools/mask/cli.py:54
#: tools/mask/cli.py:64
#: tools/mask/cli.py:35 tools/mask/cli.py:47 tools/mask/cli.py:58
#: tools/mask/cli.py:69
msgid "data"
msgstr "데이터"
#: tools/mask/cli.py:36
#: tools/mask/cli.py:39
msgid ""
"Full path to the alignments file to add the mask to if not at the default "
"location. NB: If the input-type is faces and you wish to update the "
"Full path to the alignments file that contains the masks if not at the "
"default location. NB: If the input-type is faces and you wish to update the "
"corresponding alignments file, then you must provide a value here as the "
"location cannot be automatically detected."
msgstr ""
@ -46,11 +51,11 @@ msgstr ""
"유형이 얼굴이고 해당 정렬 파일을 업데이트하려는 경우 위치를 자동으로 감지할 "
"수 없으므로 여기에 값을 제공해야 합니다."
#: tools/mask/cli.py:47
#: tools/mask/cli.py:51
msgid "Directory containing extracted faces, source frames, or a video file."
msgstr "추출된 얼굴들, 원본 프레임들, 또는 비디오 파일이 존재하는 디렉토리."
#: tools/mask/cli.py:56
#: tools/mask/cli.py:61
msgid ""
"R|Whether the `input` is a folder of faces or a folder frames/video\n"
"L|faces: The input is a folder containing extracted faces.\n"
@ -60,7 +65,7 @@ msgstr ""
"L|faces: 입력은 추출된 얼굴을 포함된 폴더입니다.\n"
"L|frames: 입력이 프레임을 포함된 폴더이거나 비디오입니다"
#: tools/mask/cli.py:65
#: tools/mask/cli.py:71
msgid ""
"R|Run the mask tool on multiple sources. If selected then the other options "
"should be set as follows:\n"
@ -83,11 +88,11 @@ msgstr ""
"(프레임용)에 있어야 합니다. 입력 유형이 '얼굴'인 마스크를 일괄 처리하는 경우 "
"추출된 얼굴 내의 PNG 헤더만 업데이트됩니다."
#: tools/mask/cli.py:81 tools/mask/cli.py:113
#: tools/mask/cli.py:87 tools/mask/cli.py:119
msgid "process"
msgstr "진행"
#: tools/mask/cli.py:82
#: tools/mask/cli.py:89
msgid ""
"R|Masker to use.\n"
"L|bisenet-fp: Relatively lightweight NN based mask that provides more "
@ -112,9 +117,8 @@ msgid ""
"some facial obstructions (hands and eyeglasses). Profile faces may result in "
"sub-par performance.\n"
"L|unet-dfl: Mask designed to provide smart segmentation of mostly frontal "
"faces. The mask model has been trained by community members and will need "
"testing for further description. Profile faces may result in sub-par "
"performance."
"faces. The mask model has been trained by community members. Profile faces "
"may result in sub-par performance."
msgstr ""
"R|사용할 마스크.\n"
"L|bisnet-fp: 전체 얼굴 마스킹(마스크 설정에서 구성 가능)을 포함하여 마스킹할 "
@ -137,32 +141,102 @@ msgstr ""
"델은 커뮤니티 구성원들에 의해 훈련되었으며 추가 설명을 위해 테스트가 필요합니"
"다. 옆 얼굴은 평균 이하의 성능을 초래할 수 있습니다."
#: tools/mask/cli.py:114
#: tools/mask/cli.py:121
msgid ""
"R|Whether to update all masks in the alignments files, only those faces that "
"do not already have a mask of the given `mask type` or just to output the "
"masks to the `output` location.\n"
"L|all: Update the mask for all faces in the alignments file.\n"
"R|The Mask tool process to perform.\n"
"L|all: Update the mask for all faces in the alignments file for the selected "
"'masker'.\n"
"L|missing: Create a mask for all faces in the alignments file where a mask "
"does not previously exist.\n"
"L|output: Don't update the masks, just output them for review in the given "
"output folder."
"does not previously exist for the selected 'masker'.\n"
"L|output: Don't update the masks, just output the selected 'masker' for "
"review/editing in external tools to the given output folder.\n"
"L|import: Import masks that have been edited outside of faceswap into the "
"alignments file. Note: 'custom' must be the selected 'masker' and the masks "
"must be in the same format as the 'input-type' (frames or faces)"
msgstr ""
"R|alignments 파일의 모든 마스크를 업데이트할지, 지정된 '마스크 유형'의 마스크"
"가 아직 없는 페이스만 업데이트할지, 아니면 단순히 '출력' 위치로 마스크를 출력"
"할지 여부.\n"
"L|all: alignments 파일의 모든 얼굴에 대한 마스크를 업데이트합니다.\n"
"L|missing: 마스크가 없었던 alignments 파일의 모든 얼굴에 대한 마스크를 만듭니"
"다.\n"
"L|output: 마스크를 업데이트하지 말고 지정된 출력 폴더에서 검토할 수 있도록 출"
"력하십시오."
"R|수행할 마스크 도구 프로세스입니다.\n"
"L|all: 선택한 'masker'에 대한 정렬 파일의 모든 면에 대한 마스크를 업데이트합"
"니다.\n"
"L|missing: 선택한 'masker'에 대해 이전에 마스크가 존재하지 않았던 정렬 파일"
"의 모든 면에 대한 마스크를 생성합니다.\n"
"L|output: 마스크를 업데이트하지 않고 외부 도구에서 검토/편집하기 위해 선택한 "
"'masker'를 지정된 출력 폴더로 출력합니다.\n"
"L|import: Faceswap 외부에서 편집된 마스크를 정렬 파일로 가져옵니다. 참고: "
"'custom'은 선택된 'masker'여야 하며 마스크는 'input-type'(frames 또는 faces)"
"과 동일한 형식이어야 합니다."
#: tools/mask/cli.py:127 tools/mask/cli.py:134 tools/mask/cli.py:147
#: tools/mask/cli.py:160 tools/mask/cli.py:169
#: tools/mask/cli.py:135 tools/mask/cli.py:154 tools/mask/cli.py:174
msgid "import"
msgstr "수입"
#: tools/mask/cli.py:137
msgid ""
"R|Import only. The path to the folder that contains masks to be imported.\n"
"L|How the masks are provided is not important, but they will be stored, "
"internally, as 8-bit grayscale images.\n"
"L|If the input are images, then the masks must be named exactly the same as "
"input frames/faces (excluding the file extension).\n"
"L|If the input is a video file, then the filename of the masks is not "
"important but should contain the frame number at the end of the filename "
"(but before the file extension). The frame number can be separated from the "
"rest of the filename by any non-numeric character and can be padded by any "
"number of zeros. The frame number must correspond correctly to the frame "
"number in the original video (starting from frame 1)."
msgstr ""
"R|가져오기만 가능합니다. 가져올 마스크가 포함된 폴더의 경로입니다.\n"
"L|마스크 제공 방법은 중요하지 않지만 내부적으로 8비트 회색조 이미지로 저장됩"
"니다.\n"
"L|입력이 이미지인 경우 마스크 이름은 입력 프레임/얼굴과 정확히 동일하게 지정"
"되어야 합니다(파일 확장자 제외).\n"
"L|입력이 비디오 파일인 경우 마스크의 파일 이름은 중요하지 않지만 파일 이름 끝"
"에(파일 확장자 앞에) 프레임 번호가 포함되어야 합니다. 프레임 번호는 숫자가 아"
"닌 문자로 파일 이름의 나머지 부분과 구분될 수 있으며 임의 개수의 0으로 채워"
"질 수 있습니다. 프레임 번호는 원본 비디오의 프레임 번호(프레임 1부터 시작)와 "
"정확하게 일치해야 합니다."
#: tools/mask/cli.py:156
msgid ""
"R|Import only. The centering to use when importing masks. Note: For any job "
"other than 'import' this option is ignored as mask centering is handled "
"internally.\n"
"L|face: Centers the mask on the center of the face, adjusting for pitch and "
"yaw. Outside of requirements for full head masking/training, this is likely "
"to be the best choice.\n"
"L|head: Centers the mask on the center of the head, adjusting for pitch and "
"yaw. Note: You should only select head centering if you intend to include "
"the full head (including hair) within the mask and are looking to train a "
"full head model.\n"
"L|legacy: The 'original' extraction technique. Centers the mask near the of "
"the nose with and crops closely to the face. Can result in the edges of the "
"mask appearing outside of the training area."
msgstr ""
"R|가져오기만. 마스크를 가져올 때 사용할 센터링입니다. 참고: '가져오기' 이외"
"의 모든 작업의 경우 마스크 센터링이 내부적으로 처리되므로 이 옵션은 무시됩니"
"다.\n"
"L|면: 피치와 요를 조정하여 마스크를 얼굴 중앙에 배치합니다. 머리 전체 마스킹/"
"훈련에 대한 요구 사항을 제외하면 이것이 최선의 선택일 가능성이 높습니다.\n"
"L|head: 마스크를 머리 중앙에 배치하여 피치와 요를 조정합니다. 참고: 마스크 내"
"에 머리 전체(머리카락 포함)를 포함하고 머리 전체 모델을 훈련시키려는 경우 머"
"리 중심 맞추기만 선택해야 합니다.\n"
"L|레거시: '원래' 추출 기술입니다. 마스크를 코 근처 중앙에 배치하고 얼굴에 가"
"깝게 자릅니다. 마스크 가장자리가 훈련 영역 외부에 나타날 수 있습니다."
#: tools/mask/cli.py:179
msgid ""
"Import only. The size, in pixels to internally store the mask at.\n"
"The default is 128 which is fine for nearly all usecases. Larger sizes will "
"result in larger alignments files and longer processing."
msgstr ""
"가져오기만. 마스크를 내부적으로 저장할 크기(픽셀)입니다.\n"
"기본값은 128이며 거의 모든 사용 사례에 적합합니다. 크기가 클수록 정렬 파일도 "
"커지고 처리 시간도 길어집니다."
#: tools/mask/cli.py:187 tools/mask/cli.py:195 tools/mask/cli.py:209
#: tools/mask/cli.py:223 tools/mask/cli.py:233
msgid "output"
msgstr "출력"
#: tools/mask/cli.py:128
#: tools/mask/cli.py:189
msgid ""
"Optional output location. If provided, a preview of the masks created will "
"be output in the given folder."
@ -170,7 +244,7 @@ msgstr ""
"선택적 출력 위치. 만약 값이 제공된다면 생성된 마스크 미리 보기가 주어진 폴더"
"에 출력됩니다."
#: tools/mask/cli.py:138
#: tools/mask/cli.py:200
msgid ""
"Apply gaussian blur to the mask output. Has the effect of smoothing the "
"edges of the mask giving less of a hard edge. the size is in pixels. This "
@ -182,7 +256,7 @@ msgstr ""
"은 홀수여야 하며 짝수가 전달되면 다음 홀수로 반올림됩니다. NB: 출력 미리 보기"
"에만 영향을 줍니다. 0으로 설정하면 꺼집니다"
#: tools/mask/cli.py:151
#: tools/mask/cli.py:214
msgid ""
"Helps reduce 'blotchiness' on some masks by making light shades white and "
"dark shades black. Higher values will impact more of the mask. NB: Only "
@ -192,7 +266,7 @@ msgstr ""
"줄이는 데 도움이 됩니다. 값이 클수록 마스크에 더 많은 영향을 미칩니다. NB: 출"
"력 미리 보기에만 영향을 줍니다. 0으로 설정하면 꺼집니다"
#: tools/mask/cli.py:161
#: tools/mask/cli.py:225
msgid ""
"R|How to format the output when processing is set to 'output'.\n"
"L|combined: The image contains the face/frame, face mask and masked face.\n"
@ -205,7 +279,7 @@ msgstr ""
"L|masked: 마스크된 얼굴/프레임을 Rgba 이미지로 출력합니다.\n"
"L|mask: 마스크를 단일 채널 이미지로만 출력합니다."
#: tools/mask/cli.py:170
#: tools/mask/cli.py:235
msgid ""
"R|Whether to output the whole frame or only the face box when using output "
"processing. Only has an effect when using frames as input."
@ -213,6 +287,25 @@ msgstr ""
"R|출력 처리를 사용할 때 전체 프레임을 출력할지 또는 페이스 박스만 출력할지 여"
"부. 프레임을 입력으로 사용할 때만 효과가 있습니다."
#~ msgid ""
#~ "R|Whether to update all masks in the alignments files, only those faces "
#~ "that do not already have a mask of the given `mask type` or just to "
#~ "output the masks to the `output` location.\n"
#~ "L|all: Update the mask for all faces in the alignments file.\n"
#~ "L|missing: Create a mask for all faces in the alignments file where a "
#~ "mask does not previously exist.\n"
#~ "L|output: Don't update the masks, just output them for review in the "
#~ "given output folder."
#~ msgstr ""
#~ "R|alignments 파일의 모든 마스크를 업데이트할지, 지정된 '마스크 유형'의 마"
#~ "스크가 아직 없는 페이스만 업데이트할지, 아니면 단순히 '출력' 위치로 마스크"
#~ "를 출력할지 여부.\n"
#~ "L|all: alignments 파일의 모든 얼굴에 대한 마스크를 업데이트합니다.\n"
#~ "L|missing: 마스크가 없었던 alignments 파일의 모든 얼굴에 대한 마스크를 만"
#~ "듭니다.\n"
#~ "L|output: 마스크를 업데이트하지 말고 지정된 출력 폴더에서 검토할 수 있도"
#~ "록 출력하십시오."
#~ msgid ""
#~ "Full path to the alignments file to add the mask to. NB: if the mask "
#~ "already exists in the alignments file it will be overwritten."

View File

@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-02-20 23:42+0000\n"
"PO-Revision-Date: 2023-04-11 16:07+0700\n"
"POT-Creation-Date: 2024-03-11 23:45+0000\n"
"PO-Revision-Date: 2024-03-11 23:50+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: ru\n"
@ -17,30 +17,34 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
"n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n"
"X-Generator: Poedit 3.2.2\n"
"X-Generator: Poedit 3.4.2\n"
#: tools/mask/cli.py:15
msgid "This command lets you generate masks for existing alignments."
msgid ""
"This tool allows you to generate, import, export or preview masks for "
"existing alignments."
msgstr ""
"Эта команда позволяет генерировать маски для существующих выравниваний."
"Этот инструмент позволяет создавать, импортировать, экспортировать или "
"просматривать маски для существующих трасс."
#: tools/mask/cli.py:24
#: tools/mask/cli.py:25
msgid ""
"Mask tool\n"
"Generate masks for existing alignments files."
"Generate, import, export or preview masks for existing alignments files."
msgstr ""
"Инструмент \"Маска\"\n"
"Создавайте маски для существующих файлов выравнивания."
"Создавайте, импортируйте, экспортируйте или просматривайте маски для "
"существующих файлов трасс."
#: tools/mask/cli.py:33 tools/mask/cli.py:44 tools/mask/cli.py:54
#: tools/mask/cli.py:64
#: tools/mask/cli.py:35 tools/mask/cli.py:47 tools/mask/cli.py:58
#: tools/mask/cli.py:69
msgid "data"
msgstr "данные"
#: tools/mask/cli.py:36
#: tools/mask/cli.py:39
msgid ""
"Full path to the alignments file to add the mask to if not at the default "
"location. NB: If the input-type is faces and you wish to update the "
"Full path to the alignments file that contains the masks if not at the "
"default location. NB: If the input-type is faces and you wish to update the "
"corresponding alignments file, then you must provide a value here as the "
"location cannot be automatically detected."
msgstr ""
@ -49,11 +53,11 @@ msgstr ""
"обновить соответствующий файл выравнивания, то вы должны указать значение "
"здесь, так как местоположение не может быть определено автоматически."
#: tools/mask/cli.py:47
#: tools/mask/cli.py:51
msgid "Directory containing extracted faces, source frames, or a video file."
msgstr "Папка, содержащая извлеченные лица, исходные кадры или видеофайл."
#: tools/mask/cli.py:56
#: tools/mask/cli.py:61
msgid ""
"R|Whether the `input` is a folder of faces or a folder frames/video\n"
"L|faces: The input is a folder containing extracted faces.\n"
@ -63,7 +67,7 @@ msgstr ""
"L|faces: Входом является папка, содержащая извлеченные лица.\n"
"L|frames: Входом является папка с кадрами или видео"
#: tools/mask/cli.py:65
#: tools/mask/cli.py:71
msgid ""
"R|Run the mask tool on multiple sources. If selected then the other options "
"should be set as follows:\n"
@ -87,11 +91,11 @@ msgstr ""
"При пакетной обработке масок с типом входа \"лица\" будут обновлены только "
"заголовки PNG в извлеченных лицах."
#: tools/mask/cli.py:81 tools/mask/cli.py:113
#: tools/mask/cli.py:87 tools/mask/cli.py:119
msgid "process"
msgstr "обработка"
#: tools/mask/cli.py:82
#: tools/mask/cli.py:89
msgid ""
"R|Masker to use.\n"
"L|bisenet-fp: Relatively lightweight NN based mask that provides more "
@ -116,9 +120,8 @@ msgid ""
"some facial obstructions (hands and eyeglasses). Profile faces may result in "
"sub-par performance.\n"
"L|unet-dfl: Mask designed to provide smart segmentation of mostly frontal "
"faces. The mask model has been trained by community members and will need "
"testing for further description. Profile faces may result in sub-par "
"performance."
"faces. The mask model has been trained by community members. Profile faces "
"may result in sub-par performance."
msgstr ""
"R|Маскер для использования.\n"
"L|bisenet-fp: Относительно легкая маска на основе NN, которая обеспечивает "
@ -146,32 +149,110 @@ msgstr ""
"сообщества и для дальнейшего описания нуждается в тестировании. Профильные "
"лица могут иметь низкую производительность."
#: tools/mask/cli.py:114
#: tools/mask/cli.py:121
msgid ""
"R|Whether to update all masks in the alignments files, only those faces that "
"do not already have a mask of the given `mask type` or just to output the "
"masks to the `output` location.\n"
"L|all: Update the mask for all faces in the alignments file.\n"
"R|The Mask tool process to perform.\n"
"L|all: Update the mask for all faces in the alignments file for the selected "
"'masker'.\n"
"L|missing: Create a mask for all faces in the alignments file where a mask "
"does not previously exist.\n"
"L|output: Don't update the masks, just output them for review in the given "
"output folder."
"does not previously exist for the selected 'masker'.\n"
"L|output: Don't update the masks, just output the selected 'masker' for "
"review/editing in external tools to the given output folder.\n"
"L|import: Import masks that have been edited outside of faceswap into the "
"alignments file. Note: 'custom' must be the selected 'masker' and the masks "
"must be in the same format as the 'input-type' (frames or faces)"
msgstr ""
"R|Обновлять ли все маски в файлах выравнивания, только те лица, которые еще "
"не имеют маски заданного `mask type` или просто выводить маски в место "
"`output`.\n"
"L|all: Обновить маску для всех лиц в файле выравнивания.\n"
"L|missing: Создать маску для всех лиц в файле выравнивания, для которых "
"маска ранее не существовала.\n"
"L|output: Не обновлять маски, а просто вывести их для просмотра в указанную "
"выходную папку."
"R|El proceso de la herramienta Máscara a realizar.\n"
"L|all: actualiza la máscara de todas las caras en el archivo de alineaciones "
"para el 'masker' seleccionado.\n"
"L|missing: crea una máscara para todas las caras en el archivo de "
"alineaciones donde no existe previamente una máscara para el 'masker' "
"seleccionado.\n"
"L|output: no actualice las máscaras, simplemente envíe el 'masker' "
"seleccionado para su revisión/edición en herramientas externas a la carpeta "
"de salida proporcionada.\n"
"L|import: importa máscaras que se han editado fuera de faceswap al archivo "
"de alineaciones. Nota: 'custom' debe ser el 'masker' seleccionado y las "
"máscaras deben tener el mismo formato que el 'input-type' (frames o faces)"
#: tools/mask/cli.py:127 tools/mask/cli.py:134 tools/mask/cli.py:147
#: tools/mask/cli.py:160 tools/mask/cli.py:169
#: tools/mask/cli.py:135 tools/mask/cli.py:154 tools/mask/cli.py:174
msgid "import"
msgstr "Импортировать"
#: tools/mask/cli.py:137
msgid ""
"R|Import only. The path to the folder that contains masks to be imported.\n"
"L|How the masks are provided is not important, but they will be stored, "
"internally, as 8-bit grayscale images.\n"
"L|If the input are images, then the masks must be named exactly the same as "
"input frames/faces (excluding the file extension).\n"
"L|If the input is a video file, then the filename of the masks is not "
"important but should contain the frame number at the end of the filename "
"(but before the file extension). The frame number can be separated from the "
"rest of the filename by any non-numeric character and can be padded by any "
"number of zeros. The frame number must correspond correctly to the frame "
"number in the original video (starting from frame 1)."
msgstr ""
"R|Только импорт. Путь к папке, содержащей маски для импорта.\n"
"L|Как предоставляются маски, не важно, но они будут храниться внутри как 8-"
"битные изображения в оттенках серого.\n"
"L|Если входными данными являются изображения, то имена масок должны быть "
"точно такими же, как у входных кадров/лиц (за исключением расширения "
"файла).\n"
"L|Если входной файл представляет собой видеофайл, то имя файла масок не "
"важно, но должно содержать номер кадра в конце имени файла (но перед "
"расширением файла). Номер кадра может быть отделен от остальной части имени "
"файла любым нечисловым символом и дополнен любым количеством нулей. Номер "
"кадра должен правильно соответствовать номеру кадра в исходном видео "
"(начиная с кадра 1)."
#: tools/mask/cli.py:156
msgid ""
"R|Import only. The centering to use when importing masks. Note: For any job "
"other than 'import' this option is ignored as mask centering is handled "
"internally.\n"
"L|face: Centers the mask on the center of the face, adjusting for pitch and "
"yaw. Outside of requirements for full head masking/training, this is likely "
"to be the best choice.\n"
"L|head: Centers the mask on the center of the head, adjusting for pitch and "
"yaw. Note: You should only select head centering if you intend to include "
"the full head (including hair) within the mask and are looking to train a "
"full head model.\n"
"L|legacy: The 'original' extraction technique. Centers the mask near the of "
"the nose with and crops closely to the face. Can result in the edges of the "
"mask appearing outside of the training area."
msgstr ""
"R|Только импорт. Центрирование, используемое при импорте масок. Примечание. "
"Для любого задания, кроме «импорта», этот параметр игнорируется, поскольку "
"центрирование маски обрабатывается внутри.\n"
"L|face: центрирует маску по центру лица с регулировкой угла наклона и "
"отклонения от курса. Помимо требований к полной маскировке/тренировке "
"головы, это, вероятно, будет лучшим выбором.\n"
"L|head: центрирует маску по центру головы с регулировкой угла наклона и "
"отклонения от курса. Примечание. Выбирать центрирование головы следует "
"только в том случае, если вы собираетесь включить в маску всю голову "
"(включая волосы) и хотите обучить модель полной головы.\n"
"L|legacy: «Оригинальная» техника извлечения. Центрирует маску возле носа и "
"приближает ее к лицу. Это может привести к тому, что края маски окажутся за "
"пределами тренировочной зоны."
#: tools/mask/cli.py:179
msgid ""
"Import only. The size, in pixels to internally store the mask at.\n"
"The default is 128 which is fine for nearly all usecases. Larger sizes will "
"result in larger alignments files and longer processing."
msgstr ""
"Только импорт. Размер в пикселях для внутреннего хранения маски.\n"
"Значение по умолчанию — 128, что подходит практически для всех случаев "
"использования. Большие размеры приведут к увеличению размера файлов "
"выравниваний и более длительной обработке."
#: tools/mask/cli.py:187 tools/mask/cli.py:195 tools/mask/cli.py:209
#: tools/mask/cli.py:223 tools/mask/cli.py:233
msgid "output"
msgstr "вывод"
#: tools/mask/cli.py:128
#: tools/mask/cli.py:189
msgid ""
"Optional output location. If provided, a preview of the masks created will "
"be output in the given folder."
@ -179,7 +260,7 @@ msgstr ""
"Необязательное местоположение вывода. Если указано, предварительный просмотр "
"созданных масок будет выведен в указанную папку."
#: tools/mask/cli.py:138
#: tools/mask/cli.py:200
msgid ""
"Apply gaussian blur to the mask output. Has the effect of smoothing the "
"edges of the mask giving less of a hard edge. the size is in pixels. This "
@ -192,7 +273,7 @@ msgstr ""
"Примечание: влияет только на предварительный просмотр. Установите значение 0 "
"для выключения"
#: tools/mask/cli.py:151
#: tools/mask/cli.py:214
msgid ""
"Helps reduce 'blotchiness' on some masks by making light shades white and "
"dark shades black. Higher values will impact more of the mask. NB: Only "
@ -203,7 +284,7 @@ msgstr ""
"часть маски. Примечание: влияет только на предварительный просмотр. "
"Установите значение 0 для выключения"
#: tools/mask/cli.py:161
#: tools/mask/cli.py:225
msgid ""
"R|How to format the output when processing is set to 'output'.\n"
"L|combined: The image contains the face/frame, face mask and masked face.\n"
@ -216,7 +297,7 @@ msgstr ""
"L|masked: Вывести лицо/кадр как изображение rgba с маскированным лицом.\n"
"L|mask: Выводить только маску как одноканальное изображение."
#: tools/mask/cli.py:170
#: tools/mask/cli.py:235
msgid ""
"R|Whether to output the whole frame or only the face box when using output "
"processing. Only has an effect when using frames as input."
@ -224,3 +305,22 @@ msgstr ""
"R|Выводить ли весь кадр или только поле лица при использовании выходной "
"обработки. Имеет значение только при использовании кадров в качестве входных "
"данных."
#~ msgid ""
#~ "R|Whether to update all masks in the alignments files, only those faces "
#~ "that do not already have a mask of the given `mask type` or just to "
#~ "output the masks to the `output` location.\n"
#~ "L|all: Update the mask for all faces in the alignments file.\n"
#~ "L|missing: Create a mask for all faces in the alignments file where a "
#~ "mask does not previously exist.\n"
#~ "L|output: Don't update the masks, just output them for review in the "
#~ "given output folder."
#~ msgstr ""
#~ "R|Обновлять ли все маски в файлах выравнивания, только те лица, которые "
#~ "еще не имеют маски заданного `mask type` или просто выводить маски в "
#~ "место `output`.\n"
#~ "L|all: Обновить маску для всех лиц в файле выравнивания.\n"
#~ "L|missing: Создать маску для всех лиц в файле выравнивания, для которых "
#~ "маска ранее не существовала.\n"
#~ "L|output: Не обновлять маски, а просто вывести их для просмотра в "
#~ "указанную выходную папку."

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-02-20 23:42+0000\n"
"POT-Creation-Date: 2024-03-11 23:45+0000\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"
@ -18,40 +18,42 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
#: tools/mask/cli.py:15
msgid "This command lets you generate masks for existing alignments."
msgid ""
"This tool allows you to generate, import, export or preview masks for "
"existing alignments."
msgstr ""
#: tools/mask/cli.py:24
#: tools/mask/cli.py:25
msgid ""
"Mask tool\n"
"Generate masks for existing alignments files."
"Generate, import, export or preview masks for existing alignments files."
msgstr ""
#: tools/mask/cli.py:33 tools/mask/cli.py:44 tools/mask/cli.py:54
#: tools/mask/cli.py:64
#: tools/mask/cli.py:35 tools/mask/cli.py:47 tools/mask/cli.py:58
#: tools/mask/cli.py:69
msgid "data"
msgstr ""
#: tools/mask/cli.py:36
#: tools/mask/cli.py:39
msgid ""
"Full path to the alignments file to add the mask to if not at the default "
"location. NB: If the input-type is faces and you wish to update the "
"Full path to the alignments file that contains the masks if not at the "
"default location. NB: If the input-type is faces and you wish to update the "
"corresponding alignments file, then you must provide a value here as the "
"location cannot be automatically detected."
msgstr ""
#: tools/mask/cli.py:47
#: tools/mask/cli.py:51
msgid "Directory containing extracted faces, source frames, or a video file."
msgstr ""
#: tools/mask/cli.py:56
#: tools/mask/cli.py:61
msgid ""
"R|Whether the `input` is a folder of faces or a folder frames/video\n"
"L|faces: The input is a folder containing extracted faces.\n"
"L|frames: The input is a folder containing frames or is a video"
msgstr ""
#: tools/mask/cli.py:65
#: tools/mask/cli.py:71
msgid ""
"R|Run the mask tool on multiple sources. If selected then the other options "
"should be set as follows:\n"
@ -65,11 +67,11 @@ msgid ""
"within the extracted faces will be updated."
msgstr ""
#: tools/mask/cli.py:81 tools/mask/cli.py:113
#: tools/mask/cli.py:87 tools/mask/cli.py:119
msgid "process"
msgstr ""
#: tools/mask/cli.py:82
#: tools/mask/cli.py:89
msgid ""
"R|Masker to use.\n"
"L|bisenet-fp: Relatively lightweight NN based mask that provides more "
@ -94,35 +96,79 @@ msgid ""
"some facial obstructions (hands and eyeglasses). Profile faces may result in "
"sub-par performance.\n"
"L|unet-dfl: Mask designed to provide smart segmentation of mostly frontal "
"faces. The mask model has been trained by community members and will need "
"testing for further description. Profile faces may result in sub-par "
"performance."
"faces. The mask model has been trained by community members. Profile faces "
"may result in sub-par performance."
msgstr ""
#: tools/mask/cli.py:114
#: tools/mask/cli.py:121
msgid ""
"R|Whether to update all masks in the alignments files, only those faces that "
"do not already have a mask of the given `mask type` or just to output the "
"masks to the `output` location.\n"
"L|all: Update the mask for all faces in the alignments file.\n"
"R|The Mask tool process to perform.\n"
"L|all: Update the mask for all faces in the alignments file for the selected "
"'masker'.\n"
"L|missing: Create a mask for all faces in the alignments file where a mask "
"does not previously exist.\n"
"L|output: Don't update the masks, just output them for review in the given "
"output folder."
"does not previously exist for the selected 'masker'.\n"
"L|output: Don't update the masks, just output the selected 'masker' for "
"review/editing in external tools to the given output folder.\n"
"L|import: Import masks that have been edited outside of faceswap into the "
"alignments file. Note: 'custom' must be the selected 'masker' and the masks "
"must be in the same format as the 'input-type' (frames or faces)"
msgstr ""
#: tools/mask/cli.py:127 tools/mask/cli.py:134 tools/mask/cli.py:147
#: tools/mask/cli.py:160 tools/mask/cli.py:169
#: tools/mask/cli.py:135 tools/mask/cli.py:154 tools/mask/cli.py:174
msgid "import"
msgstr ""
#: tools/mask/cli.py:137
msgid ""
"R|Import only. The path to the folder that contains masks to be imported.\n"
"L|How the masks are provided is not important, but they will be stored, "
"internally, as 8-bit grayscale images.\n"
"L|If the input are images, then the masks must be named exactly the same as "
"input frames/faces (excluding the file extension).\n"
"L|If the input is a video file, then the filename of the masks is not "
"important but should contain the frame number at the end of the filename "
"(but before the file extension). The frame number can be separated from the "
"rest of the filename by any non-numeric character and can be padded by any "
"number of zeros. The frame number must correspond correctly to the frame "
"number in the original video (starting from frame 1)."
msgstr ""
#: tools/mask/cli.py:156
msgid ""
"R|Import only. The centering to use when importing masks. Note: For any job "
"other than 'import' this option is ignored as mask centering is handled "
"internally.\n"
"L|face: Centers the mask on the center of the face, adjusting for pitch and "
"yaw. Outside of requirements for full head masking/training, this is likely "
"to be the best choice.\n"
"L|head: Centers the mask on the center of the head, adjusting for pitch and "
"yaw. Note: You should only select head centering if you intend to include "
"the full head (including hair) within the mask and are looking to train a "
"full head model.\n"
"L|legacy: The 'original' extraction technique. Centers the mask near the of "
"the nose with and crops closely to the face. Can result in the edges of the "
"mask appearing outside of the training area."
msgstr ""
#: tools/mask/cli.py:179
msgid ""
"Import only. The size, in pixels to internally store the mask at.\n"
"The default is 128 which is fine for nearly all usecases. Larger sizes will "
"result in larger alignments files and longer processing."
msgstr ""
#: tools/mask/cli.py:187 tools/mask/cli.py:195 tools/mask/cli.py:209
#: tools/mask/cli.py:223 tools/mask/cli.py:233
msgid "output"
msgstr ""
#: tools/mask/cli.py:128
#: tools/mask/cli.py:189
msgid ""
"Optional output location. If provided, a preview of the masks created will "
"be output in the given folder."
msgstr ""
#: tools/mask/cli.py:138
#: tools/mask/cli.py:200
msgid ""
"Apply gaussian blur to the mask output. Has the effect of smoothing the "
"edges of the mask giving less of a hard edge. the size is in pixels. This "
@ -130,14 +176,14 @@ msgid ""
"to the next odd number. NB: Only effects the output preview. Set to 0 for off"
msgstr ""
#: tools/mask/cli.py:151
#: tools/mask/cli.py:214
msgid ""
"Helps reduce 'blotchiness' on some masks by making light shades white and "
"dark shades black. Higher values will impact more of the mask. NB: Only "
"effects the output preview. Set to 0 for off"
msgstr ""
#: tools/mask/cli.py:161
#: tools/mask/cli.py:225
msgid ""
"R|How to format the output when processing is set to 'output'.\n"
"L|combined: The image contains the face/frame, face mask and masked face.\n"
@ -145,7 +191,7 @@ msgid ""
"L|mask: Only output the mask as a single channel image."
msgstr ""
#: tools/mask/cli.py:170
#: tools/mask/cli.py:235
msgid ""
"R|Whether to output the whole frame or only the face box when using output "
"processing. Only has an effect when using frames as input."

View File

@ -215,7 +215,7 @@ class FromFaces(): # pylint:disable=too-few-public-methods
alignments_path = os.path.join(self._faces_dir, fname)
dummy_args = Namespace(alignments_path=alignments_path)
aln = Alignments(dummy_args, is_extract=True)
aln._data = alignments # pylint:disable=protected-access
aln.update_from_dict(alignments)
aln._io._version = version # pylint:disable=protected-access
aln._io.update_legacy() # pylint:disable=protected-access
aln.backup()

View File

@ -795,7 +795,7 @@ class FaceUpdate():
def landmarks_rotate(self,
frame_index: int,
face_index: int,
angle: np.ndarray,
angle: float,
center: np.ndarray) -> None:
""" Rotate the landmarks on an Extract Box rotate for the
:class:`~lib.align.DetectedFace` object at the given frame and face indices for the
@ -807,7 +807,7 @@ class FaceUpdate():
The frame that the face is being set for
face_index: int
The face index within the frame
angle: :class:`numpy.ndarray`
angle: float
The angle, in radians to rotate the points by
center: :class:`numpy.ndarray`
The center point of the Landmark's Extract Box

View File

@ -1,9 +1,12 @@
#!/usr/bin/env python3
""" The Manual Tool is a tkinter driven GUI app for editing alignments files with visual tools.
This module is the main entry point into the Manual Tool. """
from __future__ import annotations
import logging
import os
import sys
import typing as T
import tkinter as tk
from tkinter import ttk
from time import sleep
@ -23,8 +26,15 @@ from .faceviewer.frame import FacesFrame
from .frameviewer.frame import DisplayFrame
from .thumbnails import ThumbsCreator
if T.TYPE_CHECKING:
from lib.align import DetectedFace
from lib.align.detected_face import Mask
from lib.queue_manager import EventQueue
logger = logging.getLogger(__name__) # pylint: disable=invalid-name
TypeManualExtractor = T.Literal["FAN", "cv2-dnn", "mask"]
class Manual(tk.Tk):
""" The main entry point for Faceswap's Manual Editor Tool. This tool is part of the Faceswap
@ -193,7 +203,7 @@ class Manual(tk.Tk):
bottom = ttk.Frame(main, name="frame_bottom")
main.add(bottom)
retval = dict(main=main, top=top, bottom=bottom)
retval = {"main": main, "top": top, "bottom": bottom}
logger.debug("Created containers: %s", retval)
return retval
@ -406,11 +416,11 @@ class TkGlobals():
self._frame_count = 0 # set by FrameLoader
self._frame_display_dims = (int(round(896 * get_config().scaling_factor)),
int(round(504 * get_config().scaling_factor)))
self._current_frame = dict(image=None,
scale=None,
interpolation=None,
display_dims=None,
filename=None)
self._current_frame = {"image": None,
"scale": None,
"interpolation": None,
"display_dims": None,
"filename": None}
logger.debug("Initialized %s", self.__class__.__name__)
@classmethod
@ -640,28 +650,38 @@ class Aligner():
A list of indices correlating to connected GPUs that Tensorflow should not use. Pass
``None`` to not exclude any GPUs.
"""
def __init__(self, tk_globals, exclude_gpus):
def __init__(self, tk_globals: TkGlobals, exclude_gpus: list[int] | None) -> None:
logger.debug("Initializing: %s (tk_globals: %s, exclude_gpus: %s)",
self.__class__.__name__, tk_globals, exclude_gpus)
self._globals = tk_globals
self._aligners = {"cv2-dnn": None, "FAN": None, "mask": None}
self._aligner = "FAN"
self._exclude_gpus = exclude_gpus
self._detected_faces = None
self._frame_index = None
self._face_index = None
self._detected_faces: DetectedFaces | None = None
self._frame_index: int | None = None
self._face_index: int | None = None
self._aligners: dict[TypeManualExtractor, Extractor | None] = {"cv2-dnn": None,
"FAN": None,
"mask": None}
self._aligner: TypeManualExtractor = "FAN"
self._init_thread = self._background_init_aligner()
logger.debug("Initialized: %s", self.__class__.__name__)
@property
def _in_queue(self):
def _in_queue(self) -> EventQueue:
""" :class:`queue.Queue` - The input queue to the extraction pipeline. """
return self._aligners[self._aligner].input_queue
aligner = self._aligners[self._aligner]
assert aligner is not None
return aligner.input_queue
@property
def _feed_face(self):
def _feed_face(self) -> ExtractMedia:
""" :class:`plugins.extract.pipeline.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
face = self._detected_faces.current_faces[self._frame_index][self._face_index]
return ExtractMedia(
self._globals.current_frame["filename"],
@ -669,22 +689,28 @@ class Aligner():
detected_faces=[face])
@property
def is_initialized(self):
def is_initialized(self) -> bool:
""" bool: The Aligners are initialized in a background thread so that other tasks can be
performed whilst we wait for initialization. ``True`` is returned if the aligner has
completed initialization otherwise ``False``."""
thread_is_alive = self._init_thread.is_alive()
if thread_is_alive:
logger.trace("Aligner not yet initialized")
logger.trace("Aligner not yet initialized") # type:ignore[attr-defined]
self._init_thread.check_and_raise_error()
else:
logger.trace("Aligner initialized")
logger.trace("Aligner initialized") # type:ignore[attr-defined]
self._init_thread.join()
return not thread_is_alive
def _background_init_aligner(self):
def _background_init_aligner(self) -> MultiThread:
""" Launch the aligner in a background thread so we can run other tasks whilst
waiting for initialization """
waiting for initialization
Returns
-------
:class:`lib.multithreading.MultiThread
The background aligner loader thread
"""
logger.debug("Launching aligner initialization thread")
thread = MultiThread(self._init_aligner,
thread_count=1,
@ -693,11 +719,11 @@ class Aligner():
logger.debug("Launched aligner initialization thread")
return thread
def _init_aligner(self):
def _init_aligner(self) -> None:
""" Initialize Aligner in a background thread, and set it to :attr:`_aligner`. """
logger.debug("Initialize Aligner")
# Make sure non-GPU aligner is allocated first
for model in ("mask", "cv2-dnn", "FAN"):
for model in T.get_args(TypeManualExtractor):
logger.debug("Initializing aligner: %s", model)
plugin = None if model == "mask" else model
exclude_gpus = self._exclude_gpus if model == "FAN" else None
@ -714,7 +740,7 @@ class Aligner():
logger.debug("Initialized %s Extractor", model)
self._aligners[model] = aligner
def link_faces(self, detected_faces):
def link_faces(self, detected_faces: DetectedFaces) -> None:
""" As the Aligner has the potential to take the longest to initialize, it is kicked off
as early as possible. At this time :class:`~tools.manual.detected_faces.DetectedFaces` is
not yet available.
@ -731,7 +757,8 @@ class Aligner():
logger.debug("Linking detected_faces: %s", detected_faces)
self._detected_faces = detected_faces
def get_landmarks(self, frame_index, face_index, aligner):
def get_landmarks(self, frame_index: int, face_index: int, aligner: TypeManualExtractor
) -> np.ndarray:
""" Feed the detected face into the alignment pipeline and retrieve the landmarks.
The face to feed into the aligner is generated from the given frame and face indices.
@ -742,7 +769,7 @@ class Aligner():
The frame index to extract the aligned face for
face_index: int
The face index within the current frame to extract the face for
aligner: ["FAN", "cv2-dnn"]
aligner: Literal["FAN", "cv2-dnn"]
The aligner to use to extract the face
Returns
@ -750,22 +777,37 @@ class Aligner():
:class:`numpy.ndarray`
The 68 point landmark alignments
"""
logger.trace("frame_index: %s, face_index: %s, aligner: %s",
logger.trace("frame_index: %s, face_index: %s, aligner: %s", # type:ignore[attr-defined]
frame_index, face_index, aligner)
self._frame_index = frame_index
self._face_index = face_index
self._aligner = aligner
self._in_queue.put(self._feed_face)
detected_face = next(self._aligners[aligner].detected_faces()).detected_faces[0]
logger.trace("landmarks: %s", detected_face.landmarks_xy)
extractor = self._aligners[aligner]
assert extractor is not None
detected_face = next(extractor.detected_faces()).detected_faces[0]
logger.trace("landmarks: %s", detected_face.landmarks_xy) # type:ignore[attr-defined]
return detected_face.landmarks_xy
def get_masks(self, frame_index, face_index):
def _remove_nn_masks(self, detected_face: DetectedFace) -> None:
""" Remove any non-landmarks based masks on a landmark edit
Parameters
----------
detected_face:
The detected face object to remove masks from
"""
del_masks = {m for m in detected_face.mask if m not in ("components", "extended")}
logger.info("Removing masks after landmark update: %s", del_masks)
for mask in del_masks:
del detected_face.mask[mask]
def get_masks(self, frame_index: int, face_index: int) -> dict[str, Mask]:
""" Feed the aligned face into the mask pipeline and retrieve the updated masks.
The face to feed into the aligner is generated from the given frame and face indices.
This is to be called when a manual update is done on the landmarks, and new masks need
generating
generating.
Parameters
----------
@ -776,30 +818,34 @@ class Aligner():
Returns
-------
dict
dict[str, :class:`~lib.align.detected_face.Mask`]
The updated masks
"""
logger.trace("frame_index: %s, face_index: %s", frame_index, face_index)
logger.trace("frame_index: %s, face_index: %s", # type:ignore[attr-defined]
frame_index, face_index)
self._frame_index = frame_index
self._face_index = face_index
self._aligner = "mask"
self._in_queue.put(self._feed_face)
assert self._aligners["mask"] is not None
detected_face = next(self._aligners["mask"].detected_faces()).detected_faces[0]
self._remove_nn_masks(detected_face)
logger.debug("mask: %s", detected_face.mask)
return detected_face.mask
def set_normalization_method(self, method):
def set_normalization_method(self, method: T.Literal["none", "clahe", "hist", "mean"]) -> None:
""" Change the normalization method for faces fed into the aligner.
The normalization method is user adjustable from the GUI. When this method is triggered
the method is updated for all aligner pipelines.
Parameters
----------
method: str
method: Literal["none", "clahe", "hist", "mean"]
The normalization method to use
"""
logger.debug("Setting normalization method to: '%s'", method)
for plugin, aligner in self._aligners.items():
assert aligner is not None
if plugin == "mask":
continue
logger.debug("Setting to: '%s'", method)

View File

@ -12,7 +12,8 @@ _LANG = gettext.translation("tools.mask.cli", localedir="locales", fallback=True
_ = _LANG.gettext
_HELPTEXT = _("This command lets you generate masks for existing alignments.")
_HELPTEXT = _("This tool allows you to generate, import, export or preview masks for existing "
"alignments.")
class MaskArgs(FaceSwapArgs):
@ -21,153 +22,217 @@ class MaskArgs(FaceSwapArgs):
@staticmethod
def get_info():
""" Return command information """
return _("Mask tool\nGenerate masks for existing alignments files.")
return _("Mask tool\nGenerate, import, export or preview masks for existing alignments "
"files.")
@staticmethod
def get_argument_list():
argument_list = []
argument_list.append(dict(
opts=("-a", "--alignments"),
action=FileFullPaths,
type=str,
group=_("data"),
required=False,
filetypes="alignments",
help=_("Full path to the alignments file to add the mask to if not at the default "
"location. NB: If the input-type is faces and you wish to update the "
"corresponding alignments file, then you must provide a value here as the "
"location cannot be automatically detected.")))
argument_list.append(dict(
opts=("-i", "--input"),
action=DirOrFileFullPaths,
type=str,
group=_("data"),
filetypes="video",
required=True,
help=_("Directory containing extracted faces, source frames, or a video file.")))
argument_list.append(dict(
opts=("-it", "--input-type"),
action=Radio,
type=str.lower,
choices=("faces", "frames"),
dest="input_type",
group=_("data"),
default="frames",
help=_("R|Whether the `input` is a folder of faces or a folder frames/video"
"\nL|faces: The input is a folder containing extracted faces."
"\nL|frames: The input is a folder containing frames or is a video")))
argument_list.append(dict(
opts=("-B", "--batch-mode"),
action="store_true",
dest="batch_mode",
default=False,
group=_("data"),
help=_("R|Run the mask tool on multiple sources. If selected then the other options "
"should be set as follows:"
"\nL|input: A parent folder containing either all of the video files to be "
"processed, or containing sub-folders of frames/faces."
"\nL|output-folder: If provided, then sub-folders will be created within the "
"given location to hold the previews for each input."
"\nL|alignments: Alignments field will be ignored for batch processing. The "
"alignments files must exist at the default location (for frames). For batch "
"processing of masks with 'faces' as the input type, then only the PNG header "
"within the extracted faces will be updated.")))
argument_list.append(dict(
opts=("-M", "--masker"),
action=Radio,
type=str.lower,
choices=PluginLoader.get_available_extractors("mask"),
default="extended",
group=_("process"),
help=_("R|Masker to use."
"\nL|bisenet-fp: Relatively lightweight NN based mask that provides more "
"refined control over the area to be masked including full head masking "
"(configurable in mask settings)."
"\nL|components: Mask designed to provide facial segmentation based on the "
"positioning of landmark locations. A convex hull is constructed around the "
"exterior of the landmarks to create a mask."
"\nL|custom: A dummy mask that fills the mask area with all 1s or 0s "
"(configurable in settings). This is only required if you intend to manually "
"edit the custom masks yourself in the manual tool. This mask does not use the "
"GPU."
"\nL|extended: Mask designed to provide facial segmentation based on the "
"positioning of landmark locations. A convex hull is constructed around the "
"exterior of the landmarks and the mask is extended upwards onto the forehead."
"\nL|vgg-clear: Mask designed to provide smart segmentation of mostly frontal "
"faces clear of obstructions. Profile faces and obstructions may result in "
"sub-par performance."
"\nL|vgg-obstructed: Mask designed to provide smart segmentation of mostly "
"frontal faces. The mask model has been specifically trained to recognize "
"some facial obstructions (hands and eyeglasses). Profile faces may result in "
"sub-par performance."
"\nL|unet-dfl: Mask designed to provide smart segmentation of mostly frontal "
"faces. The mask model has been trained by community members and will need "
"testing for further description. Profile faces may result in sub-par "
"performance.")))
argument_list.append(dict(
opts=("-p", "--processing"),
action=Radio,
type=str.lower,
choices=("all", "missing", "output"),
default="missing",
group=_("process"),
help=_("R|Whether to update all masks in the alignments files, only those faces "
"that do not already have a mask of the given `mask type` or just to output "
"the masks to the `output` location."
"\nL|all: Update the mask for all faces in the alignments file."
"\nL|missing: Create a mask for all faces in the alignments file where a mask "
"does not previously exist."
"\nL|output: Don't update the masks, just output them for review in the given "
"output folder.")))
argument_list.append(dict(
opts=("-o", "--output-folder"),
action=DirFullPaths,
dest="output",
type=str,
group=_("output"),
help=_("Optional output location. If provided, a preview of the masks created will "
"be output in the given folder.")))
argument_list.append(dict(
opts=("-b", "--blur_kernel"),
action=Slider,
type=int,
group=_("output"),
min_max=(0, 9),
default=3,
rounding=1,
help=_("Apply gaussian blur to the mask output. Has the effect of smoothing the "
"edges of the mask giving less of a hard edge. the size is in pixels. This "
"value should be odd, if an even number is passed in then it will be rounded "
"to the next odd number. NB: Only effects the output preview. Set to 0 for "
"off")))
argument_list.append(dict(
opts=("-t", "--threshold"),
action=Slider,
type=int,
group=_("output"),
min_max=(0, 50),
default=4,
rounding=1,
help=_("Helps reduce 'blotchiness' on some masks by making light shades white "
"and dark shades black. Higher values will impact more of the mask. NB: "
"Only effects the output preview. Set to 0 for off")))
argument_list.append(dict(
opts=("-ot", "--output-type"),
action=Radio,
type=str.lower,
choices=("combined", "masked", "mask"),
default="combined",
group=_("output"),
help=_("R|How to format the output when processing is set to 'output'."
"\nL|combined: The image contains the face/frame, face mask and masked face."
"\nL|masked: Output the face/frame as rgba image with the face masked."
"\nL|mask: Only output the mask as a single channel image.")))
argument_list.append(dict(
opts=("-f", "--full-frame"),
action="store_true",
default=False,
group=_("output"),
help=_("R|Whether to output the whole frame or only the face box when using "
"output processing. Only has an effect when using frames as input.")))
argument_list.append({
"opts": ("-a", "--alignments"),
"action": FileFullPaths,
"type": str,
"group": _("data"),
"required": False,
"filetypes": "alignments",
"help": _(
"Full path to the alignments file that contains the masks if not at the "
"default location. NB: If the input-type is faces and you wish to update the "
"corresponding alignments file, then you must provide a value here as the "
"location cannot be automatically detected.")})
argument_list.append({
"opts": ("-i", "--input"),
"action": DirOrFileFullPaths,
"type": str,
"group": _("data"),
"filetypes": "video",
"required": True,
"help": _(
"Directory containing extracted faces, source frames, or a video file.")})
argument_list.append({
"opts": ("-it", "--input-type"),
"action": Radio,
"type": str.lower,
"choices": ("faces", "frames"),
"dest": "input_type",
"group": _("data"),
"default": "frames",
"help": _(
"R|Whether the `input` is a folder of faces or a folder frames/video"
"\nL|faces: The input is a folder containing extracted faces."
"\nL|frames: The input is a folder containing frames or is a video")})
argument_list.append({
"opts": ("-B", "--batch-mode"),
"action": "store_true",
"dest": "batch_mode",
"default": False,
"group": _("data"),
"help": _(
"R|Run the mask tool on multiple sources. If selected then the other options "
"should be set as follows:"
"\nL|input: A parent folder containing either all of the video files to be "
"processed, or containing sub-folders of frames/faces."
"\nL|output-folder: If provided, then sub-folders will be created within the "
"given location to hold the previews for each input."
"\nL|alignments: Alignments field will be ignored for batch processing. The "
"alignments files must exist at the default location (for frames). For batch "
"processing of masks with 'faces' as the input type, then only the PNG header "
"within the extracted faces will be updated.")})
argument_list.append({
"opts": ("-M", "--masker"),
"action": Radio,
"type": str.lower,
"choices": PluginLoader.get_available_extractors("mask"),
"default": "extended",
"group": _("process"),
"help": _(
"R|Masker to use."
"\nL|bisenet-fp: Relatively lightweight NN based mask that provides more "
"refined control over the area to be masked including full head masking "
"(configurable in mask settings)."
"\nL|components: Mask designed to provide facial segmentation based on the "
"positioning of landmark locations. A convex hull is constructed around the "
"exterior of the landmarks to create a mask."
"\nL|custom: A dummy mask that fills the mask area with all 1s or 0s "
"(configurable in settings). This is only required if you intend to manually "
"edit the custom masks yourself in the manual tool. This mask does not use the "
"GPU."
"\nL|extended: Mask designed to provide facial segmentation based on the "
"positioning of landmark locations. A convex hull is constructed around the "
"exterior of the landmarks and the mask is extended upwards onto the forehead."
"\nL|vgg-clear: Mask designed to provide smart segmentation of mostly frontal "
"faces clear of obstructions. Profile faces and obstructions may result in "
"sub-par performance."
"\nL|vgg-obstructed: Mask designed to provide smart segmentation of mostly "
"frontal faces. The mask model has been specifically trained to recognize "
"some facial obstructions (hands and eyeglasses). Profile faces may result in "
"sub-par performance."
"\nL|unet-dfl: Mask designed to provide smart segmentation of mostly frontal "
"faces. The mask model has been trained by community members. Profile faces "
"may result in sub-par performance.")})
argument_list.append({
"opts": ("-p", "--processing"),
"action": Radio,
"type": str.lower,
"choices": ("all", "missing", "output", "import"),
"default": "all",
"group": _("process"),
"help": _(
"R|The Mask tool process to perform."
"\nL|all: Update the mask for all faces in the alignments file for the selected "
"'masker'."
"\nL|missing: Create a mask for all faces in the alignments file where a mask "
"does not previously exist for the selected 'masker'."
"\nL|output: Don't update the masks, just output the selected 'masker' for "
"review/editing in external tools to the given output folder."
"\nL|import: Import masks that have been edited outside of faceswap into the "
"alignments file. Note: 'custom' must be the selected 'masker' and the masks must "
"be in the same format as the 'input-type' (frames or faces)")})
argument_list.append({
"opts": ("-m", "--mask-path"),
"action": DirFullPaths,
"type": str,
"group": _("import"),
"help": _(
"R|Import only. The path to the folder that contains masks to be imported."
"\nL|How the masks are provided is not important, but they will be stored, "
"internally, as 8-bit grayscale images."
"\nL|If the input are images, then the masks must be named exactly the same as "
"input frames/faces (excluding the file extension)."
"\nL|If the input is a video file, then the filename of the masks is not "
"important but should contain the frame number at the end of the filename (but "
"before the file extension). The frame number can be separated from the rest of "
"the filename by any non-numeric character and can be padded by any number of "
"zeros. The frame number must correspond correctly to the frame number in the "
"original video (starting from frame 1).")})
argument_list.append({
"opts": ("-c", "--centering"),
"action": Radio,
"type": str.lower,
"choices": ("face", "head", "legacy"),
"default": "face",
"group": _("import"),
"help": _(
"R|Import only. The centering to use when importing masks. Note: For any job "
"other than 'import' this option is ignored as mask centering is handled "
"internally."
"\nL|face: Centers the mask on the center of the face, adjusting for "
"pitch and yaw. Outside of requirements for full head masking/training, this "
"is likely to be the best choice."
"\nL|head: Centers the mask on the center of the head, adjusting for "
"pitch and yaw. Note: You should only select head centering if you intend to "
"include the full head (including hair) within the mask and are looking to "
"train a full head model."
"\nL|legacy: The 'original' extraction technique. Centers the mask near the "
" of the nose with and crops closely to the face. Can result in the edges of "
"the mask appearing outside of the training area.")})
argument_list.append({
"opts": ("-s", "--storage-size"),
"dest": "storage_size",
"action": Slider,
"type": int,
"group": _("import"),
"min_max": (64, 1024),
"default": 128,
"rounding": 64,
"help": _(
"Import only. The size, in pixels to internally store the mask at.\nThe default "
"is 128 which is fine for nearly all usecases. Larger sizes will result in larger "
"alignments files and longer processing.")})
argument_list.append({
"opts": ("-o", "--output-folder"),
"action": DirFullPaths,
"dest": "output",
"type": str,
"group": _("output"),
"help": _(
"Optional output location. If provided, a preview of the masks created will "
"be output in the given folder.")})
argument_list.append({
"opts": ("-b", "--blur_kernel"),
"action": Slider,
"type": int,
"group": _("output"),
"min_max": (0, 9),
"default": 0,
"rounding": 1,
"help": _(
"Apply gaussian blur to the mask output. Has the effect of smoothing the "
"edges of the mask giving less of a hard edge. the size is in pixels. This "
"value should be odd, if an even number is passed in then it will be rounded "
"to the next odd number. NB: Only effects the output preview. Set to 0 for "
"off")})
argument_list.append({
"opts": ("-t", "--threshold"),
"action": Slider,
"type": int,
"group": _("output"),
"min_max": (0, 50),
"default": 0,
"rounding": 1,
"help": _(
"Helps reduce 'blotchiness' on some masks by making light shades white "
"and dark shades black. Higher values will impact more of the mask. NB: "
"Only effects the output preview. Set to 0 for off")})
argument_list.append({
"opts": ("-O", "--output-type"),
"action": Radio,
"type": str.lower,
"choices": ("combined", "masked", "mask"),
"default": "combined",
"group": _("output"),
"help": _(
"R|How to format the output when processing is set to 'output'."
"\nL|combined: The image contains the face/frame, face mask and masked face."
"\nL|masked: Output the face/frame as rgba image with the face masked."
"\nL|mask: Only output the mask as a single channel image.")})
argument_list.append({
"opts": ("-f", "--full-frame"),
"action": "store_true",
"default": False,
"group": _("output"),
"help": _(
"R|Whether to output the whole frame or only the face box when using "
"output processing. Only has an effect when using frames as input.")})
return argument_list

222
tools/mask/loader.py Normal file
View File

@ -0,0 +1,222 @@
#!/usr/bin/env python3
""" Handles loading of faces/frames from source locations and pairing with alignments
information """
from __future__ import annotations
import logging
import os
import typing as T
import numpy as np
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
if T.TYPE_CHECKING:
from lib.align import Alignments
from lib.align.alignments import PNGHeaderDict
logger = logging.getLogger(__name__)
class Loader:
""" Loader for reading source data from disk, and yielding the output paired with alignment
information
Parameters
----------
location: str
Full path to the source files location
is_faces: bool
``True`` if the source is a folder of faceswap extracted faces
"""
def __init__(self, location: str, is_faces: bool) -> None:
logger.debug("Initializing %s (location: %s, is_faces: %s)",
self.__class__.__name__, location, is_faces)
self._is_faces = is_faces
self._loader = FacesLoader(location) if is_faces else ImagesLoader(location)
self._alignments: Alignments | None = None
self._skip_count = 0
logger.debug("Initialized %s", self.__class__.__name__)
@property
def file_list(self) -> list[str]:
"""list[str]: Full file list of source files to be loaded """
return self._loader.file_list
@property
def is_video(self) -> bool:
"""bool: ``True`` if the source is a video file otherwise ``False`` """
return self._loader.is_video
@property
def location(self) -> str:
"""str: Full path to the source folder/video file location """
return self._loader.location
@property
def skip_count(self) -> int:
"""int: The number of faces/frames that have been skipped due to no match in alignments
file """
return self._skip_count
def add_alignments(self, alignments: Alignments | None) -> None:
""" Add the loaded alignments to :attr:`_alignments` for content matching
Parameters
----------
alignments: :class:`~lib.align.Alignments` | None
The alignments file object or ``None`` if not provided
"""
logger.debug("Adding alignments to loader: %s", alignments)
self._alignments = alignments
@classmethod
def _get_detected_face(cls, alignment: AlignmentFileDict) -> DetectedFace:
""" Convert an alignment dict item to a detected_face object
Parameters
----------
alignment: :class:`lib.align.alignments.AlignmentFileDict`
The alignment dict for a face
Returns
-------
:class:`~lib.align.detected_face.DetectedFace`:
The corresponding detected_face object for the alignment
"""
detected_face = DetectedFace()
detected_face.from_alignment(alignment)
return detected_face
def _process_face(self,
filename: str,
image: np.ndarray,
metadata: PNGHeaderDict) -> ExtractMedia | None:
""" Process a single face when masking from face images
Parameters
----------
filename: str
the filename currently being processed
image: :class:`numpy.ndarray`
The current face being processed
metadata: dict
The source frame metadata from the PNG header
Returns
-------
:class:`plugins.pipeline.ExtractMedia` | None
the extract media object for the processed face or ``None`` if alignment information
could not be found
"""
frame_name = metadata["source"]["source_filename"]
face_index = metadata["source"]["face_index"]
if self._alignments is None: # mask from PNG header
lookup_index = 0
alignments = [T.cast(AlignmentFileDict, metadata["alignments"])]
else: # mask from Alignments file
lookup_index = face_index
alignments = self._alignments.get_faces_in_frame(frame_name)
if not alignments or face_index > len(alignments) - 1:
self._skip_count += 1
logger.warning("Skipping Face not found in alignments file: '%s'", filename)
return None
alignment = alignments[lookup_index]
detected_face = self._get_detected_face(alignment)
retval = ExtractMedia(filename, image, detected_faces=[detected_face], is_aligned=True)
retval.add_frame_metadata(metadata["source"])
return retval
def _from_faces(self) -> T.Generator[ExtractMedia, None, None]:
""" Load content from pre-aligned faces and pair with corresponding metadata
Yields
------
:class:`plugins.pipeline.ExtractMedia`
the extract media object for the processed face
"""
log_once = False
for filename, image, metadata in tqdm(self._loader.load(), total=self._loader.count):
if not metadata: # Legacy faces. Update the headers
if self._alignments is None:
logger.error("Legacy faces have been discovered, but no alignments file "
"provided. You must provide an alignments file for this face set")
break
if not log_once:
logger.warning("Legacy faces discovered. These faces will be updated")
log_once = True
metadata = update_legacy_png_header(filename, self._alignments)
if not metadata: # Face not found
self._skip_count += 1
logger.warning("Legacy face not found in alignments file. This face has not "
"been updated: '%s'", filename)
continue
if "source_frame_dims" not in metadata.get("source", {}):
logger.error("The faces need to be re-extracted as at least some of them do not "
"contain information required to correctly generate masks.")
logger.error("You can re-extract the face-set by using the Alignments Tool's "
"Extract job.")
break
retval = self._process_face(filename, image, metadata)
if retval is None:
continue
yield retval
def _from_frames(self) -> T.Generator[ExtractMedia, None, None]:
""" Load content from frames and and pair with corresponding metadata
Yields
------
:class:`plugins.pipeline.ExtractMedia`
the extract media object for the processed face
"""
assert self._alignments is not None
for filename, image in tqdm(self._loader.load(), total=self._loader.count):
frame = os.path.basename(filename)
if not self._alignments.frame_exists(frame):
self._skip_count += 1
logger.warning("Skipping frame not in alignments file: '%s'", frame)
continue
if not self._alignments.frame_has_faces(frame):
logger.debug("Skipping frame with no faces: '%s'", frame)
continue
faces_in_frame = self._alignments.get_faces_in_frame(frame)
detected_faces = [self._get_detected_face(alignment) for alignment in faces_in_frame]
retval = ExtractMedia(filename, image, detected_faces=detected_faces)
yield retval
def load(self) -> T.Generator[ExtractMedia, None, None]:
""" Load content from source and pair with corresponding alignment data
Yields
------
:class:`plugins.pipeline.ExtractMedia`
the extract media object for the processed face
"""
if self._is_faces:
iterator = self._from_faces
else:
iterator = self._from_frames
for media in iterator():
yield media
if self._skip_count > 0:
logger.warning("%s face(s) skipped due to not existing in the alignments file",
self._skip_count)

View File

@ -4,31 +4,25 @@ from __future__ import annotations
import logging
import os
import sys
import typing as T
from argparse import Namespace
from multiprocessing import Process
import cv2
import numpy as np
from tqdm import tqdm
from lib.align import Alignments
from lib.align import Alignments, AlignedFace, DetectedFace, update_legacy_png_header
from lib.image import FacesLoader, ImagesLoader, ImagesSaver, encode_image
from lib.utils import _video_extensions
from plugins.extract.pipeline import ExtractMedia
from lib.multithreading import MultiThread
from lib.utils import get_folder, _video_extensions
from plugins.extract.pipeline import Extractor, ExtractMedia
if T.TYPE_CHECKING:
from lib.align.aligned_face import CenteringType
from lib.align.alignments import AlignmentFileDict, PNGHeaderDict
from lib.queue_manager import EventQueue
logger = logging.getLogger(__name__) # pylint:disable=invalid-name
from .loader import Loader
from .mask_import import Import
from .mask_generate import MaskGenerator
from .mask_output import Output
class Mask(): # pylint:disable=too-few-public-methods
logger = logging.getLogger(__name__)
class Mask: # pylint:disable=too-few-public-methods
""" This tool is part of the Faceswap Tools suite and should be called from
``python tools.py mask`` command.
@ -44,6 +38,10 @@ class Mask(): # pylint:disable=too-few-public-methods
"""
def __init__(self, arguments: Namespace) -> None:
logger.debug("Initializing %s: (arguments: %s", self.__class__.__name__, arguments)
if arguments.batch_mode and arguments.processing == "import":
logger.error("Batch mode is not supported for 'import' processing")
sys.exit(0)
self._args = arguments
self._input_locations = self._get_input_locations()
@ -130,7 +128,7 @@ class Mask(): # pylint:disable=too-few-public-methods
self._run_mask_process(arguments)
class _Mask(): # pylint:disable=too-few-public-methods
class _Mask: # pylint:disable=too-few-public-methods
""" This tool is part of the Faceswap Tools suite and should be called from
``python tools.py mask`` command.
@ -144,26 +142,36 @@ class _Mask(): # pylint:disable=too-few-public-methods
"""
def __init__(self, arguments: Namespace) -> None:
logger.debug("Initializing %s: (arguments: %s)", self.__class__.__name__, arguments)
self._update_type = arguments.processing
self._input_is_faces = arguments.input_type == "faces"
self._mask_type = arguments.masker
self._output = {"opts": {"blur_kernel": arguments.blur_kernel,
"threshold": arguments.threshold},
"type": arguments.output_type,
"full_frame": arguments.full_frame,
"suffix": self._get_output_suffix(arguments)}
self._counts = {"face": 0, "skip": 0, "update": 0}
self._check_input(arguments.input)
self._saver = self._set_saver(arguments)
loader = FacesLoader if self._input_is_faces else ImagesLoader
self._loader = loader(arguments.input)
self._faces_saver: ImagesSaver | None = None
self._alignments = self._get_alignments(arguments)
self._extractor = self._get_extractor(arguments.exclude_gpus)
self._set_correct_mask_type()
self._extractor_input_thread = self._feed_extractor()
self._loader = Loader(arguments.input, self._input_is_faces)
self._alignments = self._get_alignments(arguments.alignments, arguments.input)
self._output = Output(arguments, self._alignments, self._loader.file_list)
self._import = None
if self._update_type == "import":
self._import = Import(arguments.mask_path,
arguments.centering,
arguments.storage_size,
self._input_is_faces,
self._loader,
self._alignments,
arguments.input,
arguments.masker)
self._mask_gen: MaskGenerator | None = None
if self._update_type in ("all", "missing"):
self._mask_gen = MaskGenerator(arguments.masker,
self._update_type == "all",
self._input_is_faces,
self._loader,
self._alignments,
arguments.input,
arguments.exclude_gpus)
logger.debug("Initialized %s", self.__class__.__name__)
@ -184,40 +192,16 @@ class _Mask(): # pylint:disable=too-few-public-methods
sys.exit(0)
logger.debug("input '%s' is valid", mask_input)
def _set_saver(self, arguments: Namespace) -> ImagesSaver | None:
""" set the saver in a background thread
Parameters
----------
arguments: :class:`argparse.Namespace`
The :mod:`argparse` arguments as passed in from :mod:`tools.py`
Returns
-------
``None`` or :class:`lib.image.ImagesSaver`:
If output is requested, returns a :class:`lib.image.ImagesSaver` otherwise
returns ``None``
"""
if not hasattr(arguments, "output") or arguments.output is None or not arguments.output:
if self._update_type == "output":
logger.error("Processing set as 'output' but no output folder provided.")
sys.exit(0)
logger.debug("No output provided. Not creating saver")
return None
output_dir = get_folder(arguments.output, make_folder=True)
logger.info("Saving preview masks to: '%s'", output_dir)
saver = ImagesSaver(output_dir)
logger.debug(saver)
return saver
def _get_alignments(self, arguments: Namespace) -> Alignments | None:
def _get_alignments(self, alignments: str | None, input_location: str) -> Alignments | None:
""" Obtain the alignments from either the given alignments location or the default
location.
Parameters
----------
arguments: :class:`argparse.Namespace`
The :mod:`argparse` arguments as passed in from :mod:`tools.py`
alignments: str | None
Full path to the alignemnts file if provided or ``None`` if not
input_location: str
Full path to the source files to be used by the mask tool
Returns
-------
@ -225,11 +209,11 @@ class _Mask(): # pylint:disable=too-few-public-methods
If output is requested, returns a :class:`lib.image.ImagesSaver` otherwise
returns ``None``
"""
if arguments.alignments:
logger.debug("Alignments location provided: %s", arguments.alignments)
return Alignments(os.path.dirname(arguments.alignments),
filename=os.path.basename(arguments.alignments))
if self._input_is_faces and arguments.processing == "output":
if alignments:
logger.debug("Alignments location provided: %s", alignments)
return Alignments(os.path.dirname(alignments),
filename=os.path.basename(alignments))
if self._input_is_faces and self._update_type == "output":
logger.debug("No alignments file provided for faces. Using PNG Header for output")
return None
if self._input_is_faces:
@ -237,7 +221,7 @@ class _Mask(): # pylint:disable=too-few-public-methods
"be updated in the faces' PNG Header")
return None
folder = arguments.input
folder = input_location
if self._loader.is_video:
logger.debug("Alignments from Video File: '%s'", folder)
folder, filename = os.path.split(folder)
@ -246,434 +230,74 @@ class _Mask(): # pylint:disable=too-few-public-methods
logger.debug("Alignments from Input Folder: '%s'", folder)
filename = "alignments"
return Alignments(folder, filename=filename)
def _get_extractor(self, exclude_gpus: list[int]) -> Extractor | None:
""" Obtain a Mask extractor plugin and launch it
Parameters
----------
exclude_gpus: list or ``None``
A list of indices correlating to connected GPUs that Tensorflow should not use. Pass
``None`` to not exclude any GPUs.
Returns
-------
:class:`plugins.extract.pipeline.Extractor`:
The launched Extractor
"""
if self._update_type == "output":
logger.debug("Update type `output` selected. Not launching extractor")
return None
logger.debug("masker: %s", self._mask_type)
extractor = Extractor(None, None, self._mask_type, exclude_gpus=exclude_gpus)
extractor.launch()
logger.debug(extractor)
return extractor
def _set_correct_mask_type(self):
""" Some masks have multiple variants that they can be saved as depending on config options
so update the :attr:`_mask_type` accordingly
"""
if self._extractor is None or self._mask_type != "bisenet-fp":
return
# Hacky look up into masker to get the type of mask
mask_plugin = self._extractor._mask[0] # pylint:disable=protected-access
assert mask_plugin is not None
mtype = "head" if mask_plugin.config.get("include_hair", False) else "face"
new_type = f"{self._mask_type}_{mtype}"
logger.debug("Updating '%s' to '%s'", self._mask_type, new_type)
self._mask_type = new_type
def _feed_extractor(self) -> MultiThread:
""" Feed the input queue to the Extractor from a faces folder or from source frames in a
background thread
Returns
-------
:class:`lib.multithreading.Multithread`:
The thread that is feeding the extractor.
"""
masker_input = getattr(self, f"_input_{'faces' if self._input_is_faces else 'frames'}")
logger.debug("masker_input: %s", masker_input)
if self._update_type == "output":
args: tuple = tuple()
else:
assert self._extractor is not None
args = (self._extractor.input_queue, )
input_thread = MultiThread(masker_input, *args, thread_count=1)
input_thread.start()
logger.debug(input_thread)
return input_thread
def _process_face(self,
filename: str,
image: np.ndarray,
metadata: PNGHeaderDict) -> ExtractMedia | None:
""" Process a single face when masking from face images
filename: str
the filename currently being processed
image: :class:`numpy.ndarray`
The current face being processed
metadata: dict
The source frame metadata from the PNG header
Returns
-------
:class:`plugins.pipeline.ExtractMedia` or ``None``
If the update type is 'output' then nothing is returned otherwise the extract media for
the face is returned
"""
frame_name = metadata["source"]["source_filename"]
face_index = metadata["source"]["face_index"]
if self._alignments is None: # mask from PNG header
lookup_index = 0
alignments = [T.cast("AlignmentFileDict", metadata["alignments"])]
else: # mask from Alignments file
lookup_index = face_index
alignments = self._alignments.get_faces_in_frame(frame_name)
if not alignments or face_index > len(alignments) - 1:
self._counts["skip"] += 1
logger.warning("Skipping Face not found in alignments file: '%s'", filename)
return None
alignment = alignments[lookup_index]
self._counts["face"] += 1
if self._check_for_missing(frame_name, face_index, alignment):
return None
detected_face = self._get_detected_face(alignment)
if self._update_type == "output":
detected_face.image = image
self._save(frame_name, face_index, detected_face)
return None
media = ExtractMedia(filename, image, detected_faces=[detected_face], is_aligned=True)
media.add_frame_metadata(metadata["source"])
self._counts["update"] += 1
return media
def _input_faces(self, *args: tuple | tuple[EventQueue]) -> None:
""" Input pre-aligned faces to the Extractor plugin inside a thread
Parameters
----------
args: tuple
The arguments that are to be loaded inside this thread. Contains the queue that the
faces should be put to
"""
log_once = False
logger.debug("args: %s", args)
if self._update_type != "output":
queue = T.cast("EventQueue", args[0])
for filename, image, metadata in tqdm(self._loader.load(), total=self._loader.count):
if not metadata: # Legacy faces. Update the headers
if self._alignments is None:
logger.error("Legacy faces have been discovered, but no alignments file "
"provided. You must provide an alignments file for this face set")
break
if not log_once:
logger.warning("Legacy faces discovered. These faces will be updated")
log_once = True
metadata = update_legacy_png_header(filename, self._alignments)
if not metadata: # Face not found
self._counts["skip"] += 1
logger.warning("Legacy face not found in alignments file. This face has not "
"been updated: '%s'", filename)
continue
if "source_frame_dims" not in metadata.get("source", {}):
logger.error("The faces need to be re-extracted as at least some of them do not "
"contain information required to correctly generate masks.")
logger.error("You can re-extract the face-set by using the Alignments Tool's "
"Extract job.")
break
media = self._process_face(filename, image, metadata)
if media is not None:
queue.put(media)
if self._update_type != "output":
queue.put("EOF")
def _input_frames(self, *args: tuple | tuple[EventQueue]) -> None:
""" Input frames to the Extractor plugin inside a thread
Parameters
----------
args: tuple
The arguments that are to be loaded inside this thread. Contains the queue that the
faces should be put to
"""
assert self._alignments is not None
logger.debug("args: %s", args)
if self._update_type != "output":
queue = T.cast("EventQueue", args[0])
for filename, image in tqdm(self._loader.load(), total=self._loader.count):
frame = os.path.basename(filename)
if not self._alignments.frame_exists(frame):
self._counts["skip"] += 1
logger.warning("Skipping frame not in alignments file: '%s'", frame)
continue
if not self._alignments.frame_has_faces(frame):
logger.debug("Skipping frame with no faces: '%s'", frame)
continue
faces_in_frame = self._alignments.get_faces_in_frame(frame)
self._counts["face"] += len(faces_in_frame)
# To keep face indexes correct/cover off where only one face in an image is missing a
# mask where there are multiple faces we process all faces again for any frames which
# have missing masks.
if all(self._check_for_missing(frame, idx, alignment)
for idx, alignment in enumerate(faces_in_frame)):
continue
detected_faces = [self._get_detected_face(alignment) for alignment in faces_in_frame]
if self._update_type == "output":
for idx, detected_face in enumerate(detected_faces):
detected_face.image = image
self._save(frame, idx, detected_face)
else:
self._counts["update"] += len(detected_faces)
queue.put(ExtractMedia(filename, image, detected_faces=detected_faces))
if self._update_type != "output":
queue.put("EOF")
def _check_for_missing(self, frame: str, idx: int, alignment: AlignmentFileDict) -> bool:
""" Check if the alignment is missing the requested mask_type
Parameters
----------
frame: str
The frame name in the alignments file
idx: int
The index of the face for this frame in the alignments file
alignment: dict
The alignment for a face
Returns
-------
bool:
``True`` if the update_type is "missing" and the mask does not exist in the alignments
file otherwise ``False``
"""
retval = (self._update_type == "missing" and
alignment.get("mask", None) is not None and
alignment["mask"].get(self._mask_type, None) is not None)
if retval:
logger.debug("Mask pre-exists for face: '%s' - %s", frame, idx)
retval = Alignments(folder, filename=filename)
self._loader.add_alignments(retval)
return retval
def _get_output_suffix(self, arguments: Namespace) -> str:
""" The filename suffix, based on selected output options.
def _save_output(self, media: ExtractMedia) -> None:
""" Output masks to disk
Parameters
----------
arguments: :class:`argparse.Namespace`
The command line arguments for the mask tool
Returns
-------
str:
The suffix to be appended to the output filename
media: :class:`~plugins.extract.pipeline.ExtractMedia`
The extract media holding the faces to output
"""
sfx = "mask_preview_"
sfx += "face_" if not arguments.full_frame or self._input_is_faces else "frame_"
sfx += f"{arguments.output_type}.png"
return sfx
filename = os.path.basename(media.frame_metadata["source_filename"]
if self._input_is_faces else media.filename)
dims = media.frame_metadata["source_frame_dims"] if self._input_is_faces else None
for idx, face in enumerate(media.detected_faces):
face_idx = media.frame_metadata["face_index"] if self._input_is_faces else idx
face.image = media.image
self._output.save(filename, face_idx, face, frame_dims=dims)
@classmethod
def _get_detected_face(cls, alignment: AlignmentFileDict) -> DetectedFace:
""" Convert an alignment dict item to a detected_face object
def _generate_masks(self) -> None:
""" Generate masks from a mask plugin """
assert self._mask_gen is not None
Parameters
----------
alignment: dict
The alignment dict for a face
logger.info("Generating masks")
Returns
-------
:class:`lib.FacesDetect.detected_face`:
The corresponding detected_face object for the alignment
"""
detected_face = DetectedFace()
detected_face.from_alignment(alignment)
return detected_face
for media in self._mask_gen.process():
if self._output.should_save:
self._save_output(media)
def _import_masks(self) -> None:
""" Import masks that have been generated outside of faceswap """
assert self._import is not None
logger.info("Importing masks")
for media in self._loader.load():
self._import.import_mask(media)
if self._output.should_save:
self._save_output(media)
if self._alignments is not None and self._import.update_count > 0:
self._alignments.backup()
self._alignments.save()
if self._import.skip_count > 0:
logger.warning("No masks were found for %s item(s), so these have not been imported",
self._import.skip_count)
logger.info("Imported masks for %s faces of %s",
self._import.update_count, self._import.update_count + self._import.skip_count)
def _output_masks(self) -> None:
""" Output masks to selected output folder """
for media in self._loader.load():
self._save_output(media)
def process(self) -> None:
""" The entry point for the Mask tool from :file:`lib.tools.cli`. Runs the Mask process """
logger.debug("Starting masker process")
updater = getattr(self, f"_update_{'faces' if self._input_is_faces else 'frames'}")
if self._update_type != "output":
assert self._extractor is not None
if self._input_is_faces:
self._faces_saver = ImagesSaver(self._loader.location, as_bytes=True)
for extractor_output in self._extractor.detected_faces():
self._extractor_input_thread.check_and_raise_error()
updater(extractor_output)
if self._counts["update"] != 0 and self._alignments is not None:
self._alignments.backup()
self._alignments.save()
if self._update_type in ("all", "missing"):
self._generate_masks()
if self._input_is_faces:
assert self._faces_saver is not None
self._faces_saver.close()
if self._update_type == "import":
self._import_masks()
self._extractor_input_thread.join()
if self._saver is not None:
self._saver.close()
if self._update_type == "output":
self._output_masks()
if self._counts["skip"] != 0:
logger.warning("%s face(s) skipped due to not existing in the alignments file",
self._counts["skip"])
if self._update_type != "output":
if self._counts["update"] == 0:
logger.warning("No masks were updated of the %s faces seen", self._counts["face"])
else:
logger.info("Updated masks for %s faces of %s",
self._counts["update"], self._counts["face"])
self._output.close()
logger.debug("Completed masker process")
def _update_faces(self, extractor_output: ExtractMedia) -> None:
""" Update alignments for the mask if the input type is a faces folder
If an output location has been indicated, then puts the mask preview to the save queue
Parameters
----------
extractor_output: :class:`plugins.extract.pipeline.ExtractMedia`
The output from the :class:`plugins.extract.pipeline.Extractor` object
"""
assert self._faces_saver is not None
for face in extractor_output.detected_faces:
frame_name = extractor_output.frame_metadata["source_filename"]
face_index = extractor_output.frame_metadata["face_index"]
logger.trace("Saving face: (frame: %s, face index: %s)", # type: ignore
frame_name, face_index)
if self._alignments is not None:
self._alignments.update_face(frame_name, face_index, face.to_alignment())
metadata: PNGHeaderDict = {"alignments": face.to_png_meta(),
"source": extractor_output.frame_metadata}
self._faces_saver.save(extractor_output.filename,
encode_image(extractor_output.image, ".png", metadata=metadata))
if self._saver is not None:
face.image = extractor_output.image
self._save(frame_name, face_index, face)
def _update_frames(self, extractor_output: ExtractMedia) -> None:
""" Update alignments for the mask if the input type is a frames folder or video
If an output location has been indicated, then puts the mask preview to the save queue
Parameters
----------
extractor_output: :class:`plugins.extract.pipeline.ExtractMedia`
The output from the :class:`plugins.extract.pipeline.Extractor` object
"""
assert self._alignments is not None
frame = os.path.basename(extractor_output.filename)
for idx, face in enumerate(extractor_output.detected_faces):
self._alignments.update_face(frame, idx, face.to_alignment())
if self._saver is not None:
face.image = extractor_output.image
self._save(frame, idx, face)
def _save(self, frame: str, idx: int, detected_face: DetectedFace) -> None:
""" Build the mask preview image and save
Parameters
----------
frame: str
The frame name in the alignments file
idx: int
The index of the face for this frame in the alignments file
detected_face: `lib.FacesDetect.detected_face`
A detected_face object for a face
"""
assert self._saver is not None
if self._mask_type == "bisenet-fp":
mask_types = [f"{self._mask_type}_{area}" for area in ("face", "head")]
else:
mask_types = [self._mask_type]
if detected_face.mask is None or not any(mask in detected_face.mask
for mask in mask_types):
logger.warning("Mask type '%s' does not exist for frame '%s' index %s. Skipping",
self._mask_type, frame, idx)
return
for mask_type in mask_types:
if mask_type not in detected_face.mask:
# If extracting bisenet mask, then skip versions which don't exist
continue
filename = os.path.join(
self._saver.location,
f"{os.path.splitext(frame)[0]}_{idx}_{mask_type}_{self._output['suffix']}")
image = self._create_image(detected_face, mask_type)
logger.trace("filename: '%s', image_shape: %s", filename, image.shape) # type: ignore
self._saver.save(filename, image)
def _create_image(self, detected_face: DetectedFace, mask_type: str) -> np.ndarray:
""" Create a mask preview image for saving out to disk
Parameters
----------
detected_face: `lib.FacesDetect.detected_face`
A detected_face object for a face
mask_type: str
The stored mask type name to create the image for
Returns
-------
:class:`numpy.ndarray`:
A preview image depending on the output type in one of the following forms:
- Containing 3 sub images: The original face, the masked face and the mask
- The mask only
- The masked face
"""
mask = detected_face.mask[mask_type]
assert detected_face.image is not None
mask.set_blur_and_threshold(**self._output["opts"])
if not self._output["full_frame"] or self._input_is_faces:
if self._input_is_faces:
face = AlignedFace(detected_face.landmarks_xy,
image=detected_face.image,
centering=mask.stored_centering,
size=detected_face.image.shape[0],
is_aligned=True).face
else:
centering: CenteringType = ("legacy" if self._alignments is not None and
self._alignments.version == 1.0
else mask.stored_centering)
detected_face.load_aligned(detected_face.image, centering=centering, force=True)
face = detected_face.aligned.face
assert face is not None
imask = cv2.resize(detected_face.mask[mask_type].mask,
(face.shape[1], face.shape[0]),
interpolation=cv2.INTER_CUBIC)[..., None]
else:
face = np.array(detected_face.image) # cv2 fails if this comes as imageio.core.Array
imask = mask.get_full_frame_mask(face.shape[1], face.shape[0])
imask = np.expand_dims(imask, -1)
height, width = face.shape[:2]
if self._output["type"] == "combined":
masked = (face.astype("float32") * imask.astype("float32") / 255.).astype("uint8")
imask = np.tile(imask, 3)
for img in (face, masked, imask):
cv2.rectangle(img, (0, 0), (width - 1, height - 1), (255, 255, 255), 1)
out_image = np.concatenate((face, masked, imask), axis=1)
elif self._output["type"] == "mask":
out_image = imask
elif self._output["type"] == "masked":
out_image = np.concatenate([face, imask], axis=-1)
return out_image

269
tools/mask/mask_generate.py Normal file
View File

@ -0,0 +1,269 @@
#!/usr/bin/env python3
""" Handles the generation of masks from faceswap for upating into an alignments file """
from __future__ import annotations
import logging
import os
import typing as T
from lib.image import encode_image, ImagesSaver
from lib.multithreading import MultiThread
from plugins.extract.pipeline 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 .loader import Loader
logger = logging.getLogger(__name__)
class MaskGenerator: # pylint:disable=too-few-public-methods
""" Uses faceswap's extract pipeline to generate masks and update them into the alignments file
and/or extracted face PNG Headers
Parameters
----------
mask_type: str
The mask type to generate
update_all: bool
``True`` to update all faces, ``False`` to only update faces missing masks
input_is_faces: bool
``True`` if the input are faceswap extracted faces otherwise ``False``
exclude_gpus: list[int]
List of any GPU IDs that should be excluded
loader: :class:`tools.mask.loader.Loader`
The loader for loading source images/video from disk
"""
def __init__(self,
mask_type: str,
update_all: bool,
input_is_faces: bool,
loader: Loader,
alignments: Alignments | None,
input_location: str,
exclude_gpus: list[int]) -> None:
logger.debug("Initializing %s (mask_type: %s, update_all: %s, input_is_faces: %s, "
"loader: %s, alignments: %s, input_location: %s, exclude_gpus: %s)",
self.__class__.__name__, mask_type, update_all, input_is_faces, loader,
alignments, input_location, exclude_gpus)
self._update_all = update_all
self._is_faces = input_is_faces
self._alignments = alignments
self._extractor = self._get_extractor(mask_type, exclude_gpus)
self._mask_type = self._set_correct_mask_type(mask_type)
self._input_thread = self._set_loader_thread(loader)
self._saver = ImagesSaver(input_location, as_bytes=True) if input_is_faces else None
self._counts: dict[T.Literal["face", "update"], int] = {"face": 0, "update": 0}
logger.debug("Initialized %s", self.__class__.__name__)
def _get_extractor(self, mask_type, exclude_gpus: list[int]) -> Extractor:
""" Obtain a Mask extractor plugin and launch it
Parameters
----------
mask_type: str
The mask type to generate
exclude_gpus: list or ``None``
A list of indices correlating to connected GPUs that Tensorflow should not use. Pass
``None`` to not exclude any GPUs.
Returns
-------
:class:`plugins.extract.pipeline.Extractor`:
The launched Extractor
"""
logger.debug("masker: %s", mask_type)
extractor = Extractor(None, None, mask_type, exclude_gpus=exclude_gpus)
extractor.launch()
logger.debug(extractor)
return extractor
def _set_correct_mask_type(self, mask_type: str) -> str:
""" Some masks have multiple variants that they can be saved as depending on config options
Parameters
----------
mask_type: str
The mask type to generate
Returns
-------
str
The actual mask variant to update
"""
if mask_type != "bisenet-fp":
return mask_type
# Hacky look up into masker to get the type of mask
mask_plugin = self._extractor._mask[0] # pylint:disable=protected-access
assert mask_plugin is not None
mtype = "head" if mask_plugin.config.get("include_hair", False) else "face"
new_type = f"{mask_type}_{mtype}"
logger.debug("Updating '%s' to '%s'", mask_type, new_type)
return new_type
def _needs_update(self, frame: str, idx: int, face: DetectedFace) -> bool:
""" Check if the mask for the current alignment needs updating for the requested mask_type
Parameters
----------
frame: str
The frame name in the alignments file
idx: int
The index of the face for this frame in the alignments file
face: :class:`~lib.align.DetectedFace`
The dected face object to check
Returns
-------
bool:
``True`` if the mask needs to be updated otherwise ``False``
"""
if self._update_all:
return True
retval = not face.mask or face.mask.get(self._mask_type, None) is None
logger.trace("Needs updating: %s, '%s' - %s", # type:ignore[attr-defined]
retval, frame, idx)
return retval
def _feed_extractor(self, loader: Loader, extract_queue: EventQueue) -> None:
""" Process to feed the extractor from inside a thread
Parameters
----------
loader: class:`tools.mask.loader.Loader`
The loader for loading source images/video from disk
extract_queue: :class:`lib.queue_manager.EventQueue`
The input queue to the extraction pipeline
"""
for media in loader.load():
self._counts["face"] += len(media.detected_faces)
if self._is_faces:
assert len(media.detected_faces) == 1
needs_update = self._needs_update(media.frame_metadata["source_filename"],
media.frame_metadata["face_index"],
media.detected_faces[0])
else:
# To keep face indexes correct/cover off where only one face in an image is missing
# a mask where there are multiple faces we process all faces again for any frames
# which have missing masks.
needs_update = any(self._needs_update(media.filename, idx, detected_face)
for idx, detected_face in enumerate(media.detected_faces))
if not needs_update:
logger.trace("No masks need updating in '%s'", # type:ignore[attr-defined]
media.filename)
continue
logger.trace("Passing to extractor: '%s'", media.filename) # type:ignore[attr-defined]
extract_queue.put(media)
logger.debug("Terminating loader thread")
extract_queue.put("EOF")
def _set_loader_thread(self, loader: Loader) -> MultiThread:
""" Set the iterator to load ExtractMedia objects into the mask extraction pipeline
so we can just iterate through the output masks
Parameters
----------
loader: class:`tools.mask.loader.Loader`
The loader for loading source images/video from disk
"""
in_queue = self._extractor.input_queue
logger.debug("Starting load thread: (loader: %s, queue: %s)", loader, in_queue)
in_thread = MultiThread(self._feed_extractor, loader, in_queue, thread_count=1)
in_thread.start()
logger.debug("Started load thread: %s", in_thread)
return in_thread
def _update_from_face(self, media: ExtractMedia) -> None:
""" Update the alignments file and/or the extracted face
Parameters
----------
media: :class:`~lib.extract.pipeline.ExtractMedia`
The ExtractMedia object with updated masks
"""
assert len(media.detected_faces) == 1
assert self._saver is not None
fname = media.frame_metadata["source_filename"]
idx = media.frame_metadata["face_index"]
face = media.detected_faces[0]
if self._alignments is not None:
logger.trace("Updating face %s in frame '%s'", idx, fname) # type:ignore[attr-defined]
self._alignments.update_face(fname, idx, face.to_alignment())
logger.trace("Updating extracted face: '%s'", media.filename) # type:ignore[attr-defined]
meta: PNGHeaderDict = {"alignments": face.to_png_meta(), "source": media.frame_metadata}
self._saver.save(media.filename, encode_image(media.image, ".png", metadata=meta))
def _update_from_frame(self, media: ExtractMedia) -> None:
""" Update the alignments file
Parameters
----------
media: :class:`~lib.extract.pipeline.ExtractMedia`
The ExtractMedia object with updated masks
"""
assert self._alignments is not None
fname = os.path.basename(media.filename)
logger.trace("Updating %s faces in frame '%s'", # type:ignore[attr-defined]
len(media.detected_faces), fname)
for idx, face in enumerate(media.detected_faces):
self._alignments.update_face(fname, idx, face.to_alignment())
def _finalize(self) -> None:
""" Close thread and save alignments on completion """
logger.debug("Finalizing MaskGenerator")
self._input_thread.join()
if self._counts["update"] > 0 and self._alignments is not None:
logger.debug("Saving alignments")
self._alignments.backup()
self._alignments.save()
if self._saver is not None:
logger.debug("Closing face saver")
self._saver.close()
if self._counts["update"] == 0:
logger.warning("No masks were updated of the %s faces seen", self._counts["face"])
else:
logger.info("Updated masks for %s faces of %s",
self._counts["update"], self._counts["face"])
def process(self) -> T.Generator[ExtractMedia, None, None]:
""" Process the output from the extractor pipeline
Yields
------
:class:`~lib.extract.pipeline.ExtractMedia`
The ExtractMedia object with updated masks
"""
for media in self._extractor.detected_faces():
self._input_thread.check_and_raise_error()
self._counts["update"] += len(media.detected_faces)
if self._is_faces:
self._update_from_face(media)
else:
self._update_from_frame(media)
yield media
self._finalize()
logger.debug("Completed MaskGenerator process")

406
tools/mask/mask_import.py Normal file
View File

@ -0,0 +1,406 @@
#!/usr/bin/env python3
""" Import mask processing for faceswap's mask tool """
from __future__ import annotations
import logging
import os
import re
import sys
import typing as T
import cv2
from tqdm import tqdm
from lib.align import AlignedFace
from lib.image import encode_image, ImagesSaver
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 lib.align import Alignments, DetectedFace
from lib.align.alignments import PNGHeaderDict
from lib.align.aligned_face import CenteringType
logger = logging.getLogger(__name__)
class Import: # pylint:disable=too-few-public-methods
""" Import masks from disk into an Alignments file
Parameters
----------
import_path: str
The path to the input images
centering: Literal["face", "head", "legacy"]
The centering to store the mask at
storage_size: int
The size to store the mask at
input_is_faces: bool
``True`` if the input is aligned faces otherwise ``False``
loader: :class:`~tools.mask.loader.Loader`
The source file loader object
alignments: :class:`~lib.align.alignments.Alignments` | None
The alignments file object for the faces, if provided
mask_type: str
The mask type to update to
"""
def __init__(self,
import_path: str,
centering: CenteringType,
storage_size: int,
input_is_faces: bool,
loader: Loader,
alignments: Alignments | None,
input_location: str,
mask_type: str) -> None:
logger.debug("Initializing %s (import_path: %s, centering: %s, storage_size: %s, "
"input_is_faces: %s, loader: %s, alignments: %s, input_location: %s, "
"mask_type: %s)", self.__class__.__name__, import_path, centering,
storage_size, input_is_faces, loader, alignments, input_location, mask_type)
self._validate_mask_type(mask_type)
self._centering = centering
self._size = storage_size
self._is_faces = input_is_faces
self._alignments = alignments
self._re_frame_num = re.compile(r"\d+$")
self._mapping = self._generate_mapping(import_path, loader)
self._saver = ImagesSaver(input_location, as_bytes=True) if input_is_faces else None
self._counts: dict[T.Literal["skip", "update"], int] = {"skip": 0, "update": 0}
logger.debug("Initialized %s", self.__class__.__name__)
@property
def skip_count(self) -> int:
""" int: Number of masks that were skipped as they do not exist for given faces """
return self._counts["skip"]
@property
def update_count(self) -> int:
""" int: Number of masks that were skipped as they do not exist for given faces """
return self._counts["update"]
@classmethod
def _validate_mask_type(cls, mask_type: str) -> None:
""" Validate that the mask type is 'custom' to ensure user does not accidentally overwrite
existing masks they may have editted
Parameters
----------
mask_type: str
The mask type that has been selected
"""
if mask_type == "custom":
return
logger.error("Masker 'custom' must be selected for importing masks")
sys.exit(1)
@classmethod
def _get_file_list(cls, path: str) -> list[str]:
""" Check the nask folder exists and obtain the list of images
Parameters
----------
path: str
Full path to the location of mask images to be imported
Returns
-------
list[str]
list of full paths to all of the images in the mask folder
"""
if not os.path.isdir(path):
logger.error("Mask path: '%s' is not a folder", path)
sys.exit(1)
paths = get_image_paths(path)
if not paths:
logger.error("Mask path '%s' contains no images", path)
sys.exit(1)
return paths
def _warn_extra_masks(self, file_list: list[str]) -> None:
""" Generate a warning for each mask that exists that does not correspond to a match in the
source input
Parameters
----------
file_list: list[str]
List of mask files that could not be mapped to a source image
"""
if not file_list:
logger.debug("All masks exist in the source data")
return
for fname in file_list:
logger.warning("Extra mask file found: '%s'", os.path.basename(fname))
logger.warning("%s mask file(s) do not exist in the source data so will not be imported "
"(see above)", len(file_list))
def _file_list_to_frame_number(self, file_list: list[str]) -> dict[int, str]:
""" Extract frame numbers from mask file names and return as a dictionary
Parameters
----------
file_list: list[str]
List of full paths to masks to extract frame number from
Returns
-------
dict[int, str]
Dictionary of frame numbers to filenames
"""
retval: dict[int, str] = {}
for filename in file_list:
frame_num = self._re_frame_num.findall(os.path.splitext(os.path.basename(filename))[0])
if not frame_num or len(frame_num) > 1:
logger.error("Could not detect frame number from mask file '%s'. "
"Check your filenames", os.path.basename(filename))
sys.exit(1)
fnum = int(frame_num[0])
if fnum in retval:
logger.error("Frame number %s for mask file '%s' already exists from file: '%s'. "
"Check your filenames",
fnum, os.path.basename(filename), os.path.basename(retval[fnum]))
sys.exit(1)
retval[fnum] = filename
logger.debug("Files: %s, frame_numbers: %s", len(file_list), len(retval))
return retval
def _map_video(self, file_list: list[str], source_files: list[str]) -> dict[str, str]:
""" Generate the mapping between the source data and the masks to be imported for
video sources
Parameters
----------
file_list: list[str]
List of full paths to masks to be imported
source_files: list[str]
list of filenames withing the source file
Returns
-------
dict[str, str]
Source filenames mapped to full path location of mask to be imported
"""
retval = {}
unmapped = []
mask_frames = self._file_list_to_frame_number(file_list)
for filename in tqdm(source_files, desc="Mapping masks to input", leave=False):
src_idx = int(os.path.splitext(filename)[0].rsplit("_", maxsplit=1)[-1])
mapped = mask_frames.pop(src_idx, "")
if not mapped:
unmapped.append(filename)
continue
retval[os.path.basename(filename)] = mapped
if len(unmapped) == len(source_files):
logger.error("No masks map between the source data and the mask folder. "
"Check your filenames")
sys.exit(1)
self._warn_extra_masks(list(mask_frames.values()))
logger.debug("Source: %s, Mask: %s, Mapped: %s",
len(source_files), len(file_list), len(retval))
return retval
def _map_images(self, file_list: list[str], source_files: list[str]) -> dict[str, str]:
""" Generate the mapping between the source data and the masks to be imported for
folder of image sources
Parameters
----------
file_list: list[str]
List of full paths to masks to be imported
source_files: list[str]
list of filenames withing the source file
Returns
-------
dict[str, str]
Source filenames mapped to full path location of mask to be imported
"""
mask_count = len(file_list)
retval = {}
unmapped = []
for filename in tqdm(source_files, desc="Mapping masks to input", leave=False):
fname = os.path.splitext(os.path.basename(filename))[0]
mapped = next((f for f in file_list
if os.path.splitext(os.path.basename(f))[0] == fname), "")
if not mapped:
unmapped.append(filename)
continue
retval[os.path.basename(filename)] = file_list.pop(file_list.index(mapped))
if len(unmapped) == len(source_files):
logger.error("No masks map between the source data and the mask folder. "
"Check your filenames")
sys.exit(1)
self._warn_extra_masks(file_list)
logger.debug("Source: %s, Mask: %s, Mapped: %s",
len(source_files), mask_count, len(retval))
return retval
def _generate_mapping(self, import_path: str, loader: Loader) -> dict[str, str]:
""" Generate the mapping between the source data and the masks to be imported
Parameters
----------
import_path: str
The path to the input images
loader: :class:`~tools.mask.loader.Loader`
The source file loader object
Returns
-------
dict[str, str]
Source filenames mapped to full path location of mask to be imported
"""
file_list = self._get_file_list(import_path)
if loader.is_video:
retval = self._map_video(file_list, loader.file_list)
else:
retval = self._map_images(file_list, loader.file_list)
return retval
def _store_mask(self, face: DetectedFace, mask: np.ndarray) -> None:
""" Store the mask to the given DetectedFace object
Parameters
----------
face: :class:`~lib.align.detected_face.DetectedFace`
The detected face object to store the mask to
mask: :class:`numpy.ndarray`
The mask to store
"""
aligned = AlignedFace(face.landmarks_xy,
mask[..., None] if self._is_faces else mask,
centering=self._centering,
size=self._size,
is_aligned=self._is_faces,
dtype="float32")
assert aligned.face is not None
face.add_mask("custom",
aligned.face / 255.,
aligned.adjusted_matrix,
aligned.interpolators[1],
storage_size=self._size,
storage_centering=self._centering)
def _store_mask_face(self, media: ExtractMedia, mask: np.ndarray) -> None:
""" Store the mask when the input is aligned faceswap faces
Parameters
----------
media: :class:`~plugins.extract.pipeline.ExtractMedia`
The extract media object containing the face(s) to import the mask for
mask: :class:`numpy.ndarray`
The mask loaded from disk
"""
assert self._saver is not None
assert len(media.detected_faces) == 1
logger.trace("Adding mask for '%s'", media.filename) # type:ignore[attr-defined]
face = media.detected_faces[0]
self._store_mask(face, mask)
if self._alignments is not None:
idx = media.frame_metadata["source_filename"]
fname = media.frame_metadata["face_index"]
logger.trace("Updating face %s in frame '%s'", idx, fname) # type:ignore[attr-defined]
self._alignments.update_face(idx,
fname,
face.to_alignment())
logger.trace("Updating extracted face: '%s'", media.filename) # type:ignore[attr-defined]
meta: PNGHeaderDict = {"alignments": face.to_png_meta(), "source": media.frame_metadata}
self._saver.save(media.filename, encode_image(media.image, ".png", metadata=meta))
@classmethod
def _resize_mask(cls, mask: np.ndarray, dims: tuple[int, int]) -> np.ndarray:
""" Resize a mask to the given dimensions
Parameters
----------
mask: :class:`numpy.ndarray`
The mask to resize
dims: tuple[int, int]
The (height, width) target size
Returns
-------
:class:`numpy.ndarray`
The resized mask, or the original mask if no resizing required
"""
if mask.shape[:2] == dims:
return mask
logger.trace("Resizing mask from %s to %s", mask.shape, dims) # type:ignore[attr-defined]
interp = cv2.INTER_AREA if mask.shape[0] > dims[0] else cv2.INTER_CUBIC
mask = cv2.resize(mask, tuple(reversed(dims)), interpolation=interp)
return mask
def _store_mask_frame(self, media: ExtractMedia, mask: np.ndarray) -> None:
""" Store the mask when the input is frames
Parameters
----------
media: :class:`~plugins.extract.pipeline.ExtractMedia`
The extract media object containing the face(s) to import the mask for
mask: :class:`numpy.ndarray`
The mask loaded from disk
"""
assert self._alignments is not None
logger.trace("Adding %s mask(s) for '%s'", # type:ignore[attr-defined]
len(media.detected_faces), media.filename)
mask = self._resize_mask(mask, media.image_size)
for idx, face in enumerate(media.detected_faces):
self._store_mask(face, mask)
self._alignments.update_face(os.path.basename(media.filename),
idx,
face.to_alignment())
def import_mask(self, media: ExtractMedia) -> None:
""" Import the mask for the given Extract Media object
Parameters
----------
media: :class:`~plugins.extract.pipeline.ExtractMedia`
The extract media object containing the face(s) to import the mask for
"""
mask_file = self._mapping.get(os.path.basename(media.filename))
if not mask_file:
self._counts["skip"] += 1
logger.warning("No mask file found for: '%s'", os.path.basename(media.filename))
return
mask = cv2.imread(mask_file, cv2.IMREAD_GRAYSCALE)
logger.trace("Loaded mask for frame '%s': %s", # type:ignore[attr-defined]
os.path.basename(mask_file), mask.shape)
self._counts["update"] += len(media.detected_faces)
if self._is_faces:
self._store_mask_face(media, mask)
else:
self._store_mask_frame(media, mask)

515
tools/mask/mask_output.py Normal file
View File

@ -0,0 +1,515 @@
#!/usr/bin/env python3
""" Output processing for faceswap's mask tool """
from __future__ import annotations
import logging
import os
import sys
import typing as T
from argparse import Namespace
import cv2
import numpy as np
from tqdm import tqdm
from lib.align import AlignedFace
from lib.align.alignments import AlignmentDict
from lib.image import ImagesSaver, read_image_meta_batch
from lib.utils import get_folder
from scripts.fsmedia import Alignments as ExtractAlignments
if T.TYPE_CHECKING:
from lib.align import Alignments, DetectedFace
from lib.align.aligned_face import CenteringType
logger = logging.getLogger(__name__)
class Output:
""" Handles outputting of masks for preview/editting to disk
Parameters
----------
arguments: :class:`argparse.Namespace`
The command line arguments that the mask tool was called with
alignments: :class:~`lib.align.alignments.Alignments` | None
The alignments file object (or ``None`` if not provided and input is faces)
file_list: list[str]
Full file list for the loader. Used for extracting alignments from faces
"""
def __init__(self, arguments: Namespace,
alignments: Alignments | None,
file_list: list[str]) -> None:
logger.debug("Initializing %s (arguments: %s, alignments: %s, file_list: %s)",
self.__class__.__name__, arguments, alignments, len(file_list))
self._blur_kernel: int = arguments.blur_kernel
self._threshold: int = arguments.threshold
self._type: T.Literal["combined", "masked", "mask"] = arguments.output_type
self._full_frame: bool = arguments.full_frame
self._mask_type = arguments.masker
self._input_is_faces = arguments.input_type == "faces"
self._saver = self._set_saver(arguments.output, arguments.processing)
self._alignments = self._get_alignments(alignments, file_list)
self._full_frame_cache: dict[str, list[tuple[int, DetectedFace]]] = {}
logger.debug("Initialized %s", self.__class__.__name__)
@property
def should_save(self) -> bool:
"""bool: ``True`` if mask images should be output otherwise ``False`` """
return self._saver is not None
def _get_subfolder(self, output: str) -> str:
""" Obtain a subfolder within the output folder to save the output based on selected
output options.
Parameters
----------
output: str
Full path to the root output folder
Returns
-------
str:
The full path to where masks should be saved
"""
out_type = "frame" if self._full_frame else "face"
retval = os.path.join(output,
f"{self._mask_type}_{out_type}_{self._type}")
logger.info("Saving masks to '%s'", retval)
return retval
def _set_saver(self, output: str | None, processing: str) -> ImagesSaver | None:
""" set the saver in a background thread
Parameters
----------
output: str
Full path to the root output folder if provided
processing: str
The processing that has been selected
Returns
-------
``None`` or :class:`lib.image.ImagesSaver`:
If output is requested, returns a :class:`lib.image.ImagesSaver` otherwise
returns ``None``
"""
if output is None or not output:
if processing == "output":
logger.error("Processing set as 'output' but no output folder provided.")
sys.exit(0)
logger.debug("No output provided. Not creating saver")
return None
output_dir = get_folder(self._get_subfolder(output), make_folder=True)
retval = ImagesSaver(output_dir)
logger.debug(retval)
return retval
def _get_alignments(self,
alignments: Alignments | None,
file_list: list[str]) -> Alignments | None:
""" Obtain the alignments file. If input is faces and full frame output is requested then
the file needs to be generated from the input faces, if not provided
Parameters
----------
alignments: :class:~`lib.align.alignments.Alignments` | None
The alignments file object (or ``None`` if not provided and input is faces)
file_list: list[str]
Full paths to ihe mask tool input files
Returns
-------
:class:~`lib.align.alignments.Alignments` | None
The alignments file if provided and/or is required otherwise ``None``
"""
if alignments is not None or not self._full_frame:
return alignments
logger.debug("Generating alignments from faces")
data = T.cast(dict[str, AlignmentDict], {})
for _, meta in tqdm(read_image_meta_batch(file_list),
desc="Reading alignments from faces",
total=len(file_list),
leave=False):
fname = meta["itxt"]["source"]["source_filename"]
aln = meta["itxt"]["alignments"]
data.setdefault(fname, {}).setdefault("faces", # type:ignore[typeddict-item]
[]).append(aln)
dummy_args = Namespace(alignments_path="/dummy/alignments.fsa")
retval = ExtractAlignments(dummy_args, is_extract=True)
retval.update_from_dict(data)
return retval
def _get_background_frame(self, detected_faces: list[DetectedFace], frame_dims: tuple[int, int]
) -> np.ndarray:
""" Obtain the background image when final output is in full frame format. There will only
ever be one background, even when there are multiple faces
The output image will depend on the requested output type and whether the input is faces
or frames
Parameters
----------
detected_faces: list[:class:`~lib.align.detected_face.DetectedFace`]
Detected face objects for the output image
frame_dims: tuple[int, int]
The size of the original frame
Returns
-------
:class:`numpy.ndarray`
The full frame background image for applying masks to
"""
if self._type == "mask":
return np.zeros(frame_dims, dtype="uint8")
if not self._input_is_faces: # Frame is in the detected faces object
assert detected_faces[0].image is not None
return np.ascontiguousarray(detected_faces[0].image)
# Outputting to frames, but input is faces. Apply the face patches to an empty canvas
retval = np.zeros((*frame_dims, 3), dtype="uint8")
for detected_face in detected_faces:
assert detected_face.image is not None
face = AlignedFace(detected_face.landmarks_xy,
image=detected_face.image,
centering="head",
size=detected_face.image.shape[0],
is_aligned=True)
border = cv2.BORDER_TRANSPARENT if len(detected_faces) > 1 else cv2.BORDER_CONSTANT
assert face.face is not None
cv2.warpAffine(face.face,
face.adjusted_matrix,
tuple(reversed(frame_dims)),
retval,
flags=cv2.WARP_INVERSE_MAP | face.interpolators[1],
borderMode=border)
return retval
def _get_background_face(self,
detected_face: DetectedFace,
mask_centering: CenteringType,
mask_size: int) -> np.ndarray:
""" Obtain the background images when the output is faces
The output image will depend on the requested output type and whether the input is faces
or frames
Parameters
----------
detected_face: :class:`~lib.align.detected_face.DetectedFace`
Detected face object for the output image
mask_centering: Literal["face", "head", "legacy"]
The centering of the stored mask
mask_size: int
The pixel size of the stored mask
Returns
-------
list[]:class:`numpy.ndarray`]
The face background image for applying masks to for each detected face object
"""
if self._type == "mask":
return np.zeros((mask_size, mask_size), dtype="uint8")
assert detected_face.image is not None
if self._input_is_faces:
retval = AlignedFace(detected_face.landmarks_xy,
image=detected_face.image,
centering=mask_centering,
size=mask_size,
is_aligned=True).face
else:
centering: CenteringType = ("legacy" if self._alignments is not None and
self._alignments.version == 1.0
else mask_centering)
detected_face.load_aligned(detected_face.image,
size=mask_size,
centering=centering,
force=True)
retval = detected_face.aligned.face
assert retval is not None
return retval
def _get_background(self,
detected_faces: list[DetectedFace],
frame_dims: tuple[int, int],
mask_centering: CenteringType,
mask_size: int) -> np.ndarray:
""" Obtain the background image that the final outut will be placed on
Parameters
----------
detected_faces: list[:class:`~lib.align.detected_face.DetectedFace`]
Detected face objects for the output image
frame_dims: tuple[int, int]
The size of the original frame
mask_centering: Literal["face", "head", "legacy"]
The centering of the stored mask
mask_size: int
The pixel size of the stored mask
Returns
-------
:class:`numpy.ndarray`
The background image for the mask output
"""
if self._full_frame:
retval = self._get_background_frame(detected_faces, frame_dims)
else:
assert len(detected_faces) == 1 # If outputting faces, we should only receive 1 face
retval = self._get_background_face(detected_faces[0], mask_centering, mask_size)
logger.trace("Background image (size: %s, dtype: %s)", # type:ignore[attr-defined]
retval.shape, retval.dtype)
return retval
def _get_mask(self,
detected_faces: list[DetectedFace],
mask_type: str,
mask_dims: tuple[int, int]) -> np.ndarray:
""" Generate the mask to be applied to the final output frame
Parameters
----------
detected_faces: list[:class:`~lib.align.detected_face.DetectedFace`]
Detected face objects to generate the masks from
mask_type: str
The mask-type to use
mask_dims : tuple[int, int]
The size of the mask to output
Returns
-------
:class:`numpy.ndarray`
The final mask to apply to the output image
"""
retval = np.zeros(mask_dims, dtype="uint8")
for face in detected_faces:
mask_object = face.mask[mask_type]
mask_object.set_blur_and_threshold(blur_kernel=self._blur_kernel,
threshold=self._threshold)
if self._full_frame:
mask = mask_object.get_full_frame_mask(*reversed(mask_dims))
else:
mask = mask_object.mask[..., 0]
np.maximum(retval, mask, out=retval)
logger.trace("Final mask (shape: %s, dtype: %s)", # type:ignore[attr-defined]
retval.shape, retval.dtype)
return retval
def _build_output_image(self, background: np.ndarray, mask: np.ndarray) -> np.ndarray:
""" Collate the mask and images for the final output image, depending on selected output
type
Parameters
----------
background: :class:`numpy.ndarray`
The image that the mask will be applied to
mask: :class:`numpy.ndarray`
The mask to output
Returns
-------
:class:`numpy.ndarray`
The final output image
"""
if self._type == "mask":
return mask
mask = mask[..., None]
if self._type == "masked":
return np.concatenate([background, mask], axis=-1)
height, width = background.shape[:2]
masked = (background.astype("float32") * mask.astype("float32") / 255.).astype("uint8")
mask = np.tile(mask, 3)
for img in (background, masked, mask):
cv2.rectangle(img, (0, 0), (width - 1, height - 1), (255, 255, 255), 1)
axis = 0 if background.shape[0] < background.shape[1] else 1
retval = np.concatenate((background, masked, mask), axis=axis)
return retval
def _create_image(self,
detected_faces: list[DetectedFace],
mask_type: str,
frame_dims: tuple[int, int] | None) -> np.ndarray:
""" Create a mask preview image for saving out to disk
Parameters
----------
detected_faces: list[:class:`~lib.align.detected_face.DetectedFace`]
Detected face objects for the output image
mask_type: str
The mask_type to process
frame_dims: tuple[int, int] | None
The size of the original frame, if input is faces otherwise ``None``
Returns
-------
:class:`numpy.ndarray`:
A preview image depending on the output type in one of the following forms:
- Containing 3 sub images: The original face, the masked face and the mask
- The mask only
- The masked face
"""
assert detected_faces[0].image is not None
dims = T.cast(tuple[int, int],
frame_dims if self._input_is_faces else detected_faces[0].image.shape[:2])
assert dims is not None and len(dims) == 2
mask_centering = detected_faces[0].mask[mask_type].stored_centering
mask_size = detected_faces[0].mask[mask_type].stored_size
background = self._get_background(detected_faces, dims, mask_centering, mask_size)
mask = self._get_mask(detected_faces,
mask_type,
dims if self._full_frame else (mask_size, mask_size))
retval = self._build_output_image(background, mask)
logger.trace("Output image (shape: %s, dtype: %s)", # type:ignore[attr-defined]
retval.shape, retval.dtype)
return retval
def _handle_cache(self,
frame: str,
idx: int,
detected_face: DetectedFace) -> list[tuple[int, DetectedFace]]:
""" For full frame output, cache any faces until all detected faces have been seen. For
face output, just return the detected_face object inside a list
Parameters
----------
frame: str
The frame name in the alignments file
idx: int
The index of the face for this frame in the alignments file
detected_face: :class:`~lib.align.detected_face.DetectedFace`
A detected_face object for a face
Returns
-------
list[tuple[int, :class:`~lib.align.detected_face.DetectedFace`]]
Face index and detected face objects to be processed for this output, if any
"""
if not self._full_frame:
return [(idx, detected_face)]
assert self._alignments is not None
faces_in_frame = self._alignments.count_faces_in_frame(frame)
if faces_in_frame == 1:
return [(idx, detected_face)]
self._full_frame_cache.setdefault(frame, []).append((idx, detected_face))
if len(self._full_frame_cache[frame]) != faces_in_frame:
logger.trace("Caching face for frame '%s'", frame) # type:ignore[attr-defined]
return []
retval = self._full_frame_cache.pop(frame)
logger.trace("Processing '%s' from cache: %s", frame, retval) # type:ignore[attr-defined]
return retval
def _get_mask_types(self,
frame: str,
detected_faces: list[tuple[int, DetectedFace]]) -> list[str]:
""" Get the mask type names for the select mask type. Remove any detected faces where
the selected mask does not exist
Parameters
----------
frame: str
The frame name in the alignments file
idx: int
The index of the face for this frame in the alignments file
detected_face: list[tuple[int, :class:`~lib.align.detected_face.DetectedFace`]
The face index and detected_face object for output
Returns
-------
list[str]
List of mask type names to be processed
"""
if self._mask_type == "bisenet-fp":
mask_types = [f"{self._mask_type}_{area}" for area in ("face", "head")]
else:
mask_types = [self._mask_type]
final_masks = set()
for idx in reversed(range(len(detected_faces))):
face_idx, detected_face = detected_faces[idx]
if detected_face.mask is None or not any(mask in detected_face.mask
for mask in mask_types):
logger.warning("Mask type '%s' does not exist for frame '%s' index %s. Skipping",
self._mask_type, frame, face_idx)
del detected_faces[idx]
continue
final_masks.update([m for m in detected_face.mask if m in mask_types])
retval = list(final_masks)
logger.trace("Handling mask types: %s", retval) # type:ignore[attr-defined]
return retval
def save(self,
frame: str,
idx: int,
detected_face: DetectedFace,
frame_dims: tuple[int, int] | None = None) -> None:
""" Build the mask preview image and save
Parameters
----------
frame: str
The frame name in the alignments file
idx: int
The index of the face for this frame in the alignments file
detected_face: :class:`~lib.align.detected_face.DetectedFace`
A detected_face object for a face
frame_dims: tuple[int, int] | None, optional
The size of the original frame, if input is faces otherwise ``None``. Default: ``None``
"""
assert self._saver is not None
faces = self._handle_cache(frame, idx, detected_face)
if not faces:
return
mask_types = self._get_mask_types(frame, faces)
if not faces or not mask_types:
logger.debug("No valid faces/masks to process for '%s'", frame)
return
for mask_type in mask_types:
detected_faces = [f[1] for f in faces if mask_type in f[1].mask]
if not detected_face:
logger.warning("No '%s' masks to output for '%s'", mask_type, frame)
continue
if len(detected_faces) != len(faces):
logger.warning("Some '%s' masks are missing for '%s'", mask_type, frame)
image = self._create_image(detected_faces, mask_type, frame_dims)
filename = os.path.splitext(frame)[0]
if len(mask_types) > 1:
filename += f"_{mask_type}"
if not self._full_frame:
filename += f"_{idx}"
filename = os.path.join(self._saver.location, f"{filename}.png")
logger.trace("filename: '%s', image_shape: %s", filename, image.shape) # type: ignore
self._saver.save(filename, image)
def close(self) -> None:
""" Shut down the image saver if it is open """
if self._saver is None:
return
logger.debug("Shutting down saver")
self._saver.close()