diff --git a/lib/align/alignments.py b/lib/align/alignments.py index 33ff2f6123..d3cb13bc03 100644 --- a/lib/align/alignments.py +++ b/lib/align/alignments.py @@ -426,7 +426,7 @@ def get_faces_in_frame(self, frame_name: str) -> list[AlignmentFileDict]: 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 @@ def delete_face_at_index(self, frame_name: str, face_index: int) -> bool: """ 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 @@ def add_face(self, frame_name: str, face: AlignmentFileDict) -> int: 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 @@ def filter_faces(self, filter_dict: dict[str, list[int]], filter_out: bool = Fal 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 diff --git a/locales/es/LC_MESSAGES/tools.mask.cli.mo b/locales/es/LC_MESSAGES/tools.mask.cli.mo index df48b2c1dd..31203ae988 100644 Binary files a/locales/es/LC_MESSAGES/tools.mask.cli.mo and b/locales/es/LC_MESSAGES/tools.mask.cli.mo differ diff --git a/locales/es/LC_MESSAGES/tools.mask.cli.po b/locales/es/LC_MESSAGES/tools.mask.cli.po index 50ef6d2a5e..253c539004 100644 --- a/locales/es/LC_MESSAGES/tools.mask.cli.po +++ b/locales/es/LC_MESSAGES/tools.mask.cli.po @@ -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." -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." - -#: 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 +"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|Процесс инструмента «Маска», который необходимо выполнить.\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: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." diff --git a/locales/kr/LC_MESSAGES/tools.mask.cli.mo b/locales/kr/LC_MESSAGES/tools.mask.cli.mo index 992863ecf1..b400456c94 100644 Binary files a/locales/kr/LC_MESSAGES/tools.mask.cli.mo and b/locales/kr/LC_MESSAGES/tools.mask.cli.mo differ diff --git a/locales/kr/LC_MESSAGES/tools.mask.cli.po b/locales/kr/LC_MESSAGES/tools.mask.cli.po index fb1554f97b..00ad1bed0a 100644 --- a/locales/kr/LC_MESSAGES/tools.mask.cli.po +++ b/locales/kr/LC_MESSAGES/tools.mask.cli.po @@ -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." -msgstr "" -"R|alignments 파일의 모든 마스크를 업데이트할지, 지정된 '마스크 유형'의 마스크" -"가 아직 없는 페이스만 업데이트할지, 아니면 단순히 '출력' 위치로 마스크를 출력" -"할지 여부.\n" -"L|all: alignments 파일의 모든 얼굴에 대한 마스크를 업데이트합니다.\n" -"L|missing: 마스크가 없었던 alignments 파일의 모든 얼굴에 대한 마스크를 만듭니" +"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|수행할 마스크 도구 프로세스입니다.\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: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|output: 마스크를 업데이트하지 말고 지정된 출력 폴더에서 검토할 수 있도록 출" -"력하십시오." +"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: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: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." diff --git a/locales/ru/LC_MESSAGES/tools.mask.cli.mo b/locales/ru/LC_MESSAGES/tools.mask.cli.mo index 2256ac5ec9..0f567b5d3f 100644 Binary files a/locales/ru/LC_MESSAGES/tools.mask.cli.mo and b/locales/ru/LC_MESSAGES/tools.mask.cli.mo differ diff --git a/locales/ru/LC_MESSAGES/tools.mask.cli.po b/locales/ru/LC_MESSAGES/tools.mask.cli.po index f741cc1741..38f0ba46b8 100644 --- a/locales/ru/LC_MESSAGES/tools.mask.cli.po +++ b/locales/ru/LC_MESSAGES/tools.mask.cli.po @@ -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." -msgstr "" -"R|Обновлять ли все маски в файлах выравнивания, только те лица, которые еще " -"не имеют маски заданного `mask type` или просто выводить маски в место " -"`output`.\n" -"L|all: Обновить маску для всех лиц в файле выравнивания.\n" -"L|missing: Создать маску для всех лиц в файле выравнивания, для которых " -"маска ранее не существовала.\n" -"L|output: Не обновлять маски, а просто вывести их для просмотра в указанную " -"выходную папку." - -#: 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 +"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|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: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: Не обновлять маски, а просто вывести их для просмотра в " +#~ "указанную выходную папку." diff --git a/locales/tools.mask.cli.pot b/locales/tools.mask.cli.pot index 6cd8348e93..fd1b65b3d5 100644 --- a/locales/tools.mask.cli.pot +++ b/locales/tools.mask.cli.pot @@ -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 \n" "Language-Team: LANGUAGE \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: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: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: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." diff --git a/tools/alignments/jobs_faces.py b/tools/alignments/jobs_faces.py index 06fcf85673..3dae491e5d 100644 --- a/tools/alignments/jobs_faces.py +++ b/tools/alignments/jobs_faces.py @@ -215,7 +215,7 @@ def _save_alignments(self, 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() diff --git a/tools/manual/detected_faces.py b/tools/manual/detected_faces.py index cdc67849ae..0f9cf1cc1b 100644 --- a/tools/manual/detected_faces.py +++ b/tools/manual/detected_faces.py @@ -795,7 +795,7 @@ def landmarks(self, frame_index: int, face_index: int, shift_x: int, shift_y: in 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 @@ def landmarks_rotate(self, 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 diff --git a/tools/manual/manual.py b/tools/manual/manual.py index a5a378fcdf..0f05d37fb8 100644 --- a/tools/manual/manual.py +++ b/tools/manual/manual.py @@ -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 .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 @@ def _create_containers(self): 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 @@ def __init__(self, input_location): 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 @@ def _feed_face(self): 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 @@ def _background_init_aligner(self): 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 @@ def _init_aligner(self): 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 @@ def link_faces(self, detected_faces): 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 @@ def get_landmarks(self, frame_index, face_index, 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 @@ def get_landmarks(self, frame_index, face_index, 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 @@ def get_masks(self, frame_index, face_index): 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) diff --git a/tools/mask/cli.py b/tools/mask/cli.py index e691870604..6b9392a39e 100644 --- a/tools/mask/cli.py +++ b/tools/mask/cli.py @@ -12,7 +12,8 @@ _ = _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 diff --git a/tools/mask/loader.py b/tools/mask/loader.py new file mode 100644 index 0000000000..020272511a --- /dev/null +++ b/tools/mask/loader.py @@ -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) diff --git a/tools/mask/mask.py b/tools/mask/mask.py index 3ce0062f02..f584bb5cdf 100644 --- a/tools/mask/mask.py +++ b/tools/mask/mask.py @@ -4,31 +4,25 @@ 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 +from .loader import Loader +from .mask_import import Import +from .mask_generate import MaskGenerator +from .mask_output import Output -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 +logger = logging.getLogger(__name__) -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. @@ -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 @@ def process(self) -> None: 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 @@ def _check_input(self, mask_input: str) -> None: 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 @@ def _get_alignments(self, arguments: Namespace) -> Alignments | None: 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 @@ def _get_alignments(self, arguments: Namespace) -> Alignments | None: "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 @@ def _get_alignments(self, arguments: Namespace) -> Alignments | None: 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. - - Parameters - ---------- - arguments: :class:`argparse.Namespace` - The command line arguments for the mask tool - - Returns - ------- - str: - The suffix to be appended to the output filename - """ - 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 - - @classmethod - def _get_detected_face(cls, alignment: AlignmentFileDict) -> DetectedFace: - """ Convert an alignment dict item to a detected_face object + def _save_output(self, media: ExtractMedia) -> None: + """ Output masks to disk Parameters ---------- - alignment: dict - The alignment dict for a face - - Returns - ------- - :class:`lib.FacesDetect.detected_face`: - The corresponding detected_face object for the alignment + media: :class:`~plugins.extract.pipeline.ExtractMedia` + The extract media holding the faces to output """ - detected_face = DetectedFace() - detected_face.from_alignment(alignment) - return detected_face + 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) + + def _generate_masks(self) -> None: + """ Generate masks from a mask plugin """ + assert self._mask_gen is not None + + logger.info("Generating masks") + + 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._input_is_faces: - assert self._faces_saver is not None - self._faces_saver.close() - - self._extractor_input_thread.join() - if self._saver is not None: - self._saver.close() - - 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"]) - 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 + if self._update_type in ("all", "missing"): + self._generate_masks() - 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 + if self._update_type == "import": + self._import_masks() - 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 + if self._update_type == "output": + self._output_masks() - 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 + self._output.close() + logger.debug("Completed masker process") diff --git a/tools/mask/mask_generate.py b/tools/mask/mask_generate.py new file mode 100644 index 0000000000..4b09172ce6 --- /dev/null +++ b/tools/mask/mask_generate.py @@ -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") diff --git a/tools/mask/mask_import.py b/tools/mask/mask_import.py new file mode 100644 index 0000000000..a2af5ec59f --- /dev/null +++ b/tools/mask/mask_import.py @@ -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) diff --git a/tools/mask/mask_output.py b/tools/mask/mask_output.py new file mode 100644 index 0000000000..344332a9b3 --- /dev/null +++ b/tools/mask/mask_output.py @@ -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()